☰
系统学一遍TypeScript:类型基础、泛型与工程配置避坑指南
2026/9/27 0:17:40 网站建设 项目流程

1. 为什么现在还要系统学一遍 TypeScript 基础

1.1 从"能用"到"会写"的差距

先说个我自己的真实感受:TypeScript 学起来最尴尬的阶段,不是完全不懂,而是"每个类型都知道一点,但一写项目就全是 any"。面试的时候问"TypeScript 的了解程度",大部分人能说出 typeof、interface、泛型这几个词,但问到"什么时候用接口、什么时候用类型别名""为什么不要到处用 any""tsconfig 里那些废弃提示到底在说什么",就卡住了。

这其实很正常,因为 TypeScript 本身不是一门"新语言",它是给 JavaScript 加了一套类型系统。你用 JS 三年能写出能跑的功能,但不一定就能写出类型合理的 TS。基础不夯实,后期遇到类型报错、依赖库的类型声明不匹配、编译速度变慢等问题,会非常痛苦。这一篇我就把最核心的基础盘点一遍,重点不是罗列语法,而是讲清楚每一种写法解决什么问题、实际项目中怎么选。

1.2 新版废弃项提示给了我们什么信号

最近用 TypeScript 5.x 初始化项目时,很多朋友会看到这样一段警告:

选项“baseurl”已弃用,并将停止在 TypeScript 7.0 中运行。请使用“paths”而不使用“baseurl”进行配置,后者目前不需要此选项。 选项“moduleresolution=node10”已弃用,并将停止在 TypeScript 7.0 中运行。请指定“moduleresolution=node16”或“moduleresolution=bundler”。

这不是报错,但它的价值比报错还大。它说明 TypeScript 正在推进模块解析策略的现代化。从前的 Node10 解析方式对应的是 Node.js 早期那种require的查找习惯,现在前端构建工具百花齐放,bundler模式更适合 Vite、webpack 这类工具。基础学习阶段不一定要立刻搞懂全部细节,但必须知道:配置选项不是随便抄的,版本升级后旧写法可能失效。我在下文第五章会专门把 tsconfig 里的几个关键点和迁移思路讲透。

2. 基础类型体系,把 JS 的动态"翻译"成静态

2.1 原始类型、数组、元组、枚举怎么用不拧巴

最基础的 string、number、boolean 没什么好说的,关键是别写出"垃圾代码":

let name: string = '张三'; let age: number = 18;

真正容易拧巴的是数组和元组。数组是同构的,元组是异构且有长度限制的。看这个例子:

// 数组 const list: number[] = [1, 2, 3]; const list2: Array<number> = [1, 2, 3]; // 等价 // 元组 const tuple: [string, number] = ['张三', 18];

使用元组时要注意:push 操作是允许的,TypeScript 并不会阻止你往元组里硬塞新元素。所以在业务代码中,我一般建议把元组当作"定长对"来用,例如useState的返回值[state, setState],不要试图在元组上做数组操作,否则类型保护的意义就削减了大半。

枚举的使用就更讲究了。字符串枚举比数字枚举更可读,但要记住:枚举本质上是对象,会有运行时开销。在基础阶段可以先用字符串字面量联合类型代替枚举,比如:

type Status = 'pending' | 'success' | 'error';

这种方式在类型提示上更直观,也没有额外产物,而且配合as const可以拿到只读对象。等真正需要做反向映射或业务集中管理时,再考虑回归 enum。

2.2 字面量类型与类型推断的默契配合

刚接触 TS 时我有个坏习惯:每个变量都手动标注类型,仿佛不写类型就不叫 TypeScript。写多了才发现,结构体、对象字面量、函数返回值这些场景,TypeScript 的自动推断已经做得很好了,你只需要在"边界"处标类型。

字面量类型是理解推断的钥匙。比如:

const direction = 'left'; // 类型是 'left' let direction2 = 'left'; // 类型是 string

const声明的变量本来就是不可变的,所以 TS 推断出的是精确的字面量类型;let可能被重新赋值,所以放宽成 string。这个细节在判断函数参数时特别有用:

function setAlign(align: 'left' | 'center' | 'right') {}

如果你用一个 string 变量传参,哪怕它的值恰好是 'left',也会报错。因为类型系统看的是"类型空间",不是"值空间"。解决方法是使用as const:

const config = { align: 'left' } as const; setAlign(config.align); // 通过

用as const后,对象的属性会被推断成字面量类型,而不是被拓宽成 string。这个技巧在写配置对象、路由表、常量字典时能减少大量类型断言。

2.3 类型别名 vs 接口,什么时候选谁

interface 和 type alias 能互相覆盖的功能很多,但工程上的选择逻辑并不复杂。

我自己的经验是:对对象、类的形状描述,优先用 interface;对联合类型、交叉类型、工具类型转换后的结果,用 type。interface 最大的优势是声明合并和 extends 的语义清晰,这在写第三方库声明、给全局对象补充类型时特别重要。type alias 的优势是灵活,它可以描述原始类型、联合类型、元组,还能结合Partial<T>、Pick<T>这类工具生成新类型。

看一个实际场景。比如我们要描述一个 API 的响应结构:

interface ApiResponse<T> { code: number; data: T; message: string; } type User = { id: number; name: string; }; type UserApiResponse = ApiResponse<User>;

interface 定义了通用的容器结构,type 负责组合业务类型,两者配合比互相替代要顺畅得多。基础阶段最容易犯的错是"能用 interface 的地方全用 interface,能用 type 的地方也全用 type",把二者搞成对立关系。正确做法是按"语义"区分,而不是按心情区分。

3. 函数与类:面向对象的静态化实践

3.1 函数重载与可选参数的最佳实践

函数的类型标注最容易忽略的是"调用者视角"。TS 的函数类型有两种写法,我直接列出来对比:

// 写法一:直接将类型写在函数声明上 function greet(name: string, greeting?: string): string { return greeting ? `${greeting}, ${name}` : `Hello, ${name}`; } // 写法二:使用类型别名描述函数形状 type GreetFn = (name: string, greeting?: string) => string; const greet: GreetFn = (name, greeting) => greeting ? `${greeting}, ${name}` : `Hello, ${name}`;

可选参数要放在必选参数之后,这个规则基础但重要。还有参数默认值的问题:当参数有默认值时,TS 会推断它是可选参数,类型是string而不是string | undefined,但因为默认值保证了一定有值,调用时也可以只传前两个参数,这点和 JS 行为一致。

函数重载则要非常小心。TS 的重载不是真正运行时多态,它只是"声明多个调用签名,但实现体只有一个"。我见过很多新手写出这样的代码:

function format(input: string): string; function format(input: number): string; function format(input: string | number): string { return typeof input === 'string' ? input.trim() : input.toFixed(2); }

这样写没错,但重载签名必须放在实现签名之前,而且实现签名的参数类型必须能兼容所有重载签名。如果你把实现签名放在最上面,TS 会报错。还有一个经验是:重载的数量不要铺开太多,超过两三个的时候,建议考虑泛型或者直接收窄为联合类型参数,否则维护成本很高。

3.2 类里的 public/private/protected 真不一定按 Java 那套来

很多有 Java/C# 背景的同学刚写 TS 时会习惯性地把所有属性都设置为 private,然后通过 getter/setter 访问。这并不算错,但在前端项目里过度封装会让代码变得非常啰嗦。TS 的private只是编译期的访问限制,并不是真正意义上的运行时私有,用#开头的 ES 私有字段才是真正的私有。

这里有一个让我印象深刻的坑:TS 的 private 在结构类型系统中可能造成误判。比如,两个类都有私有属性#secret存在时,它们之间是不兼容的。这不是大问题,但你要是把一个类的实例赋给另一个类型的变量,类型检查就会报错。反而是protected和public用起来更自然。

实际项目中,我更推荐遵循"最少暴露"原则,但不要教条化:

class PaymentService { constructor(private readonly amount: number, private readonly fee: number) {} get total(): number { return this.amount + this.fee; } }

参数属性(在构造函数参数里直接写private readonly amount)是 TS 的语法糖,能少写很多声明代码。这里的readonly也很重要,它避免了在类方法里意外修改核心配置。

3.3 抽象类与接口在工程里的分工

抽象类用于"模板方法"场景,接口用于"能力契约"场景。这是我在多个项目里反复验证后的结论。

假设我们要实现一个数据上报模块。有的上报到日志平台,有的上报到监控系统,它们之间有公共逻辑:数据清洗、重试、发送。但具体发送目标不同。这时候用抽象类很合适:

abstract class Reporter { protected constructor(protected readonly apiKey: string) {} protected cleanData(raw: unknown): unknown { return JSON.parse(JSON.stringify(raw)); } abstract send(payload: unknown): Promise<void>; async report(raw: unknown): Promise<void> { const cleaned = this.cleanData(raw); await this.send(cleaned); } } class LogReporter extends Reporter { async send(payload: unknown): Promise<void> { // 发送到日志平台 } }

而接口更适合描述"一个类具备什么能力"。比如Logger接口,定义info/warn/error方法,不管底层是 console 还是上报系统,调用方只依赖接口。工程项目中我倾向"面向接口编程"的距离感,但实现公共流程时用抽象类减少重复代码。二者不是替代关系,各自处理不同层级的抽象。

4. 泛型:从"类型参数"到组件抽象

4.1 为什么不建议把 any 当万能膏药

any太香了,任何类型报错都能用any抹掉。但你职业生涯里写掉的第一根头发,大概率就是从any开始掉的。any会让 TypeScript 退化成 JavaScript,而且在团队协作中,任何人读到你的一堆any都无法确认这个函数输入输出到底是什么。

基础阶段要先建立"任何类型的解药是泛型,不是 any"的自觉。举个最常见的例子——一个获取对象属性的函数:

// 坏味道:any 胡一把 function getProp(obj: any, key: string): any { return obj[key]; } // 正确姿势:泛型 + 约束 function getProp<T extends object, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; }

后者在调用时能自动推导出返回类型。如果你只是访问一层属性,甚至不需要手动传泛型参数,编译器自己搞定。这就是泛型的作用:它把"不同类型之间共同的结构"抽象出来,同时保留每种类型的具体信息。

4.2 泛型约束、默认值与工具类型初探

泛型约束用extends关键字,但这里的 extends 不是"继承"的意思,而是"满足某种结构"的意思。比如:

type Lengthwise = { length: number }; function logLength<T extends Lengthwise>(arg: T): T { console.log(arg.length); return arg; }

这个约束表示:不管传入的是什么类型,都必须有一个 number 类型的 length 属性。字符串、数组都能通过,因为它们的 length 属性是数字。数字类型的 length 不存在,就无法通过编译。

泛型默认值常用于构建组件或高阶函数。比如做一个异步请求封装:

interface HttpResponse<T = unknown> { code: number; data: T; } function request<T = unknown>(url: string): Promise<HttpResponse<T>> { return fetch(url).then(r => r.json()); }

默认值unknown能保证在不明确指定类型时也不会退化成 any,这比默认T = any安全得多。我在编写公共库时,默认泛型参数基本都用 unknown 或具体可用的类型,目的是强制调用方思考自己需要什么类型。

4.3 常用内置工具类型源码级理解

工具类型如Partial<T>、Required<T>、Pick<T, K>、Omit<T, K>、Record<K, T>等,别看它们长得复杂,源码其实很清晰。以Partial为例:

type Partial<T> = { [P in keyof T]?: T[P]; };

它遍历 T 的所有属性,把每个属性的值类型保留,但加上?表示可选。Readonly<T>是同样的映射,只是加上readonly修饰符。

Record<K, T>用来快速构造对象类型:

type Role = 'admin' | 'user' | 'guest'; const rolePermissions: Record<Role, string[]> = { admin: ['create', 'delete', 'update'], user: ['read', 'update'], guest: ['read'], };

Pick和Omit在很多业务场景里是真正的高频工具。比如一个 User 对象有 10 个字段,创建用户接口只需要其中 4 个,这时候用Pick<User, 'name' | 'email' | 'password'>就能快速提取出需要的类型,而不需要重新声明一遍。理解源码能帮你规避两个问题:一是不要用Omit<T, 'a' | 'b'>去删不存在的属性(类型不会报错但没意义),二是工具类型不会做深层递归,嵌套对象结构需要你自己设计一些递归映射类型。

5. 现代 TypeScript 工程配置避坑实录

5.1 tsconfig.json 里几个必懂的选项

很多教程开头就是"创建 tsconfig.json,然后复制现成配置",但复制久了就不知道自己到底开没开严格检查。我建议至少在三个选项上花点时间。

strict是最核心的开关,它包含了strictNullChecks、noImplicitAny、noImplicitThis等一系列严格性检查。现在的官方脚手架基本都默认开启,老项目改造时可以先把 strict 设为 false,逐步收拢,但新项目必须开。strictNullChecks的影响最大:开启后,null和undefined不再赋值给普通类型,这就逼着你处理空值情况。

target决定输出的 JS 版本,module决定模块规范。实际业务中,你写代码的地方可能和构建工具的处理方式有关。比如 Vite 会建议"module": "ESNext",Node 服务端则可能用"module": "NodeNext"。基础阶段先记住:这两项不要照抄网上旧配置,要根据运行环境决定。

verbatimModuleSyntax是 5.x 版本之后越来越有用的选项,它要求你在导入类型时必须使用import type。这在编译器开启isolatedModules后能避免很多歧义,也方便打包工具做 tree-shaking。实际上,我现在的习惯是:凡是一个导入只用于类型位置,就一律写import type,代码可读性和编译效率都能提升。

5.2 baseurl 与 paths 的新旧变化,怎么迁移

回到开头提到的废弃提示。baseurl曾经和paths配合使用,来为模块解析设置"根目录"和路径别名:

{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }

在旧版本里,baseurl 是 paths 的前置条件,路径别名的解析依赖它。但在 TypeScript 5.0 之后,单独指定paths已经不需要 baseurl 了。也就是说,直接删掉baseUrl字段,保留paths,别名的解析依然正常。

我实际迁移过一个中型项目,删掉 baseUrl 后需要检查两处:一是路径别名的书写方式,二是某些动态导入或者import.meta相关的解析行为。如果遇到模块解析报错,可以把paths里面的相对路径改为相对于 tsconfig.json 所在目录的写法。建议新项目直接不写 baseUrl,只写 paths,这也是当前官方推荐的做法。如果你还在维护老项目,升级 TS 后看见这个 warning,不用慌,按提示改掉就是了。

5.3 moduleResolution node10 已废弃,用什么替代

node10这个名字很容易让人误会,它的别名是node,指的是旧版 Node.js 的 CommonJS 解析算法。这个算法只支持require查找 node_modules 里的包,对文件扩展名和exports字段的处理很粗糙。现在的 npm 包大量使用exports字段来控制子路径导出,旧解析方式会忽略它们,导致类型或运行时拿到不正确的文件。

替代方案有两个主流选择:

{ "compilerOptions": { "moduleResolution": "bundler", "module": "ESNext" } }

bundler模式适合 Vite/webpack 这类打包器,它支持exports字段、import.meta.url、目录导入等特性。node16/nodenext则适合纯 Node.js 环境,它会根据 package.json 里的type字段判断模块格式。

从我自己用 webpack 5 和 Vite 的项目来看,新项目无脑选bundler基本不会有问题,除非你确实在写 Node 服务端且需要 ESM/CJS 互操作。遇到"模块只能用 CommonJS 引用"之类的报错时,可以优先检查这个选项。

5.4 在 vue3+three.js 和 electron 打包场景中的一点体会

这里补充两个真实场景,因为很多读者是在这些场景里第一次接触 TS 配置的。

第一个场景是 vue3 + three.js。three.js 的类型定义在多年迭代后做得相当好,但在使用OrbitControls等扩展时,有时需要显式导入类型:

import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

这类路径一般没有问题,但如果你的 tsconfig 里有moduleResolution: "node"(旧版),就有可能导致 three.js 的examples/jsm路径无法解析。把它改成"bundler"后问题通常会消失。还有一个小技巧:three.js 里很多对象构造参数是复杂的联合类型,不想手动标注时,可以用Parameters<typeof someFunction>这种工具类型取出来,减少重复劳动。

第二个场景是 electron 打包。Electron 主进程和渲染进程的 TS 配置常常是分开的。主进程代码频繁使用 Node 内置模块,渲染进程则使用 DOM 或框架类型。如果一份 tsconfig 混用,会非常难受。我的建议是创建三个配置:tsconfig.main.json、tsconfig.renderer.json、tsconfig.node.json(用于 vite 配置等),通过references关联。构建时分别运行vue-tsc或者tsc -p,这样类型检查更严格,也不会互相污染。在 electron 打包流程里遇到类型报错,先看是不是moduleResolution的问题,再考虑types字段是否需要显式声明node类型。

6. 面试常考的基础题,其实考的是思考方式

6.1 如何回答"satisfies 和 as 的区别"

as是类型断言,它告诉编译器"我知道我在做什么,请把我的类型覆盖成目标类型"。但断言有风险,它只是编译期行为,如果实际运行时值不符合断言,就会埋雷。satisfies是 TS 4.9 引入的操作符,它不改变变量的推断类型,只用来"检查这个值是否满足某个类型"。

举一个能明显体现区别的例子:

type Colors = 'red' | 'green' | 'blue'; const palette = { primary: '#ff0000', secondary: '#00ff00', } satisfies Record<string, string>;

这里如果只用as Record<string, string>,palette.primary 的类型会变成 string,丢失字面量信息。用 satisfies 后,palette.primary 依然保持string的推断?这里要注意:satisfies 不会把类型收窄成更精确的#ff0000字面量,它只负责校验。实际上,当左侧对象是普通字符串字面量时,推断类型是 string,所以 primary 是 string。但如果是as const之后再 satisfies,那就能保留字面量类型了。

这种细节很难一下子说全,但面试时只要点出"as 是强制覆盖,satisfies 是校验且保留推断类型"这个核心逻辑,就能体现你理解到了本质上。

6.2 几个容易答错的类型题

第一题:typeof和keyof的区别。typeof在类型上下文里取出的是一个值的类型,keyof取出的是一个类型的所有键的联合。两者经常组合使用,比如:

const user = { name: 'John', age: 20 }; type UserType = typeof user; // { name: string; age: number } type UserKeys = keyof UserType; // 'name' | 'age'

第二题:interface和type是否可以直接互相 extends?答案是 interface 可以 extends type,但 type alias 可以 extends interface 吗?这里不严谨。正确的说法是:type 能通过交叉类型&包含 interface 的形状,但不能像 interface 那样"继承"。所以严格来说 interface extends type 可以,type 使用交叉类型操作符组合 interface 也可以,但 type 没有 extends 关键字直接继承 interface 的语法。

第三题:unknown和any的区别。unknown是类型安全的顶层类型,你不能在未经收窄的情况下直接使用它;any则绕过了类型检查。实际写业务时,遇到外部输入(比如JSON.parse的返回值),先用unknown接收,再做类型收窄,是最规范的做法。用any接收再到处断言,早晚会出事。

第四题:optional chaining和non-null assertion的区别。a?.b是运行时保护,如果 a 为 null/undefined,表达式结果为 undefined;a!.b是编译期声明"a 一定是非空",运行时如果 a 是空的,照样报错。分清这两者,可以避免很多"类型上对了但运行崩了"的问题。

最后聊一点自己的体会

写作这一篇的时候,我特意回翻了几个老项目,发现当年写下的baseurl和moduleResolution: "node"现在都成了废弃项,这其实挺能说明问题的。TypeScript 基础语法本身变化不大,但工程配置和最佳实践每年都在演进。学基础的时候不要太抠语法细节,更重要的是把握设计思想:类型系统是为了让代码更可预测,不是在给你添堵。如果你刚上手 TypeScript,先花时间把 strict 打开,把 any 一点点清掉,把 tsconfig 里每个选项的意义搞清楚,这才是最值得投入的方向。下一篇我会继续写类型体操和实战中的声明文件设计,我们到时候接着聊。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询