TypeScript
Roadmap
GPT 4.1 眼中的 TypeScript 路线图?
入门阶段
- TypeScript 与 JavaScript 的区别与优势
- tsconfig.json 基本配置
- 基础类型(number、string、boolean、array、tuple、enum、any、unknown、void、null、undefined、never等)
- 接口(interface)与类型别名(type)
- 函数类型、可选参数、默认参数、剩余参数
- 类(class)与继承
- 模块化与 import/export
- 基本类型推断与断言
进阶阶段
- 高级类型(联合类型、交叉类型、字面量类型、映射类型)
- 泛型(泛型函数、泛型接口、泛型类、泛型约束、默认泛型参数)
- 条件类型与 infer 用法
- 类型守卫、类型收窄、用户自定义类型保护
- 内置工具类型(Partial、Pick、Record、ReturnType、Parameters、Awaited等)
- 模块声明与第三方库类型声明(DefinitelyTyped @types/*, d.ts 文件)
- 类型兼容性/协变与逆变
- 装饰器(Decorator)
高级阶段
- 类型体操(复杂条件类型、多层 infer、类型递归、模板字面量类型)
- 自定义类型工具(封装复用型工具类型、类型运算优化)
- 项目架构层面的类型设计(如事件系统、插件系统、依赖注入等的类型安全实现方式)
- 利用类型系统实现 DSL(领域特定语言)
- 大型团队/库的 tsconfig 管理与类型隔离
- 与 Babel、Webpack、ESLint 等构建工具联动,类型在工具链中的协作
- 与前端框架深度结合(如 React 的高级类型、Vue 的类型推导、Svelte)
- 参与和贡献 TypeScript 生态(如 PR DefinitelyTyped、参与类型相关 RFC)
模块系统
模块解析如何工作?
TS 的模块解析语法上完全兼容 ESM,解析规则大体基于 NodeJS 的模块解析规则。基于 NodeJS 解析规则的前提是配置了 moduleResolution: node,当然,其他选项基本上不会用到。
略过 NodeJS 模块解析规则,TS 的文件名匹配过程如下:
- 判断是否是 TS 文件,是则匹配完成。
- 判断是否是文件夹,是且找到该文件夹下的 index.ts 文件,则匹配完成。
- 判断是否是文件夹,是且找到该文件夹下的 package.json 文件,则继续 4、5。
- 如果 moduleResolution 配置 node16 或 nodenext,则优先匹配 package.json 的 exports 字段。
- 其次匹配 package.json 中的 types、main、module 字段。
如何修改全局命名空间下的类型?
在项目特定目录创建的 global.d.ts 文件会自动加载并合并到全局模块中。而在文件模块中,也可以使用 declare global 来声明全局模块。
declare global {
interface String {
endsWith(suffix: string): boolean;
}
}
如何保留有副作用的导入?
当模块设置了 sideEffects false 时,TS 和编译工具会认为该模块没有副作用,并可能将部分代码摇树优化掉。使用以下写法可以强制保留副作用模块的导入:
// method 1
import './foo'
import './bar'
// method 2
require('./foo')
require('./bar')
// method 3
import foo = require('./foo')
import bar = require('./bar')
const ensureImport: any = foo || bar
为什么不推荐使用 namespace?
namespace 主要用于将代码分块,会被编译成如下代码。而既然 ES Module 已经能做好这件事,随着 ES 标准逐渐流行,也就不再需要 namespace 这种多余的抽象了。
// 编译前
namespace Utility {}
// 编译后
(function (Utility) {
})(Utility || Utility = {});
为什么 TS 支持导入 CSS 文件?
语法上 TS 没有限制能导入什么类型的文件,而 TS 编译器本身不会去解析 CSS 文件的内容,只要你写了 import "./index.css",TS 编译器会尝试类型检查。如果没有合适的类型声明,会报错。所以一般仍然需要声明文件的类型,通常是通过 declare module "*.css"
来声明 CSS 模块。
类型系统
全局类型声明是如何运作的?
全局类型声明即 @types 类型包。默认情况下,TypeScript 会自动包含所有在 node_modules/@types 文件夹下能找到的类型声明包。但如果明确设置了 compilerOptions.types,只有列表中指定的类型声明包会被包含进来,其他的都不会自动包含。
类型接口应该如何使用?
类型接口应用于面向对象编程中对可继承或可扩展对象、类、函数接口的设计,而类型别名适用于此外几乎所有场景。
在实际应用中,常使用到类型接口声明合并的功能,比如:
interface Window { foo: number; }
interface Window { bar: string; }
// Window 等同于 { foo: number; bar: string }
枚举类型是怎么编译的?
一个简单的编译示例,两段代码分别是枚举类型源码和编译结果。这种值和字面量双向映射的特性,也意味着很好地通过运行时实现了类型和值的统一。但如果声明枚举时指定了字面量(如下代码中的“Unknown”),则编译结果会变成名字到值的单向映射。
enum Tristate {
False = 5,
True,
Unknown = "Unknown"
}
var Tristate;
(function (Tristate) {
Tristate[Tristate["False"] = 5] = "False";
Tristate[Tristate["True"] = 6] = "True";
Tristate["Unknown"] = "Unknown";
})(Tristate || (Tristate = {}));
通过上面的例子,能发现从可变行为而言,枚举和数字类型是互相兼容的。但需要严格区分类型检测行为和运行时行为,比如以下代码有类型错误,但运行结果为 true:
enum Status {
Ready,
Waiting
}
enum Color {
Red,
Blue,
Green
}
const [myStatus, myColor] = [Status.Ready, Color.Red]
//@ts-expect-error
console.log(myStatus === myColor) // -> true
如何定义可函数重载的类型?
type LongHandAllowsOverloadDeclarations = {
(a: number): number;
(a: string): string;
}
泛型是什么?
泛型是一种允许编写代码时不指定具体的数据类型,而是在使用时再传入具体的类型的技术。设计泛型的关键目的是给类实例、类方法、函数参数、函数返回值等成员提供有意义的约束。
在代码中,简单泛型通常按照惯例用 T、U、V 等单字母表示,如:
function identity<T extends string>(arg: T): T {
return arg;
}
裸类型是什么?
裸类型在带入运算时会自动展开,非裸类型则不会。
type WrapNaked<T> = T extends any ? { o: T } : never
type WrapUnNaked<T> = { o: T } extends any ? { o: T } : never
type Foo = WrapNaked<string | number | boolean>
// { o: string } | { o: number } | { o: boolean }
type Bar = WrapUnNaked<string | number | boolean>
// { o: string | number | boolean }
Enums VS Literal Unions
- Georges Haidar 提到枚举相比字面量联合 支持 TSDoc 的 Deprecated 声明
高级类型
什么是新鲜性检查(Freshness)?
当使用对象字面量传参或赋值时,TS 会额外检查是否有未声明的(多余的)属性。如果想要跳过这种检查,可以使用变量中转。
interface Person {
name: string
}
const printPerson = (p: Person) => console.log(p.name)
//@ts-expect-error
printPerson({ name: 'Alice', age: 18 })
//@ts-expect-error
const p1: Person = { name: 'Alice', age: 18 }
const p2 = { name: 'Alice', age: 18 }
// ok
printPerson(p2)
Iterable 和 Array 是什么关系?
实现了 Symbol.iterator 接口的对象都视为是 Iterable 的对象,所以 Array、Map、Set、String、Int32Array 等都是 Iterable 的。
TS 泛型对比 C++ 模版元编程
C++ 的模板机制支持更底层的类型参数化,且是编译时的计算。例举几个 C++ 模版能做到而 TS 类型系统做不到的特性:
- 编译期计算:编译期递归计算斐波那契数,最终把结果作为常量用在代码中
template <int N>
struct Fib {
static constexpr int value = Fib<N-1>::value + Fib<N-2>::value;
};
template <>
struct Fib<1> { static constexpr int value = 1; };
template <>
struct Fib<0> { static constexpr int value = 0; };
constexpr int f10 = Fib<10>::value; // 编译期常量 = 55
- 模板特化:可以为特定类型提供不同的实现
// 1. 通用模板
template<typename T>
void process(T val) { /* 通用实现 */ }
// 2. 针对 int 优化
template<>
void process<int>(int val) { /* 针对 int 的高效实现 */ }
内部工具类型推导?
什么是类型体操?
TypeScript 的类型系统是图灵完备的,这意味着能用 JavaScript 解决的问题,用 TypeScript 的类型系统同样能解决,只是实现方式不一样。尽管完成某种“实现”的方式可能非常复杂,但人们就像玩玩具一样喜欢玩它,或是把它看作某种脑力运动。充满技巧,有乐趣,复杂,又花里胡哨,所以是体操咯。
什么是图灵完备?
图灵完备通常指一门编程语言,这意味着这门语言能完成所有图灵机能做的工作。图灵机简单来说,是一个带有无限长度纸带的机器,纸带上有规律组成的 0 和 1,而图灵机通过读写纸带,就可以模拟任何可以实现的计算。
编译器
经验
大规模采用 TypeScript 之后的 10 个见解
尽管 TypeScript Design Goals 明确表示了避免在未来引入更多的运行时特征,但在发展过程中,TS 扩展了一小部分不太适合 TS = JS + Types 这个模型的功能,enum, namespace, parameter properties 以及 experimental decorators 都需要有将他们扩展为运行时代码的语义,而 JavaScript 引擎很可能永远都不会为这些功能提供支持。
彭博社有专门的工具阻止使用这些不良设计,他们希望自己的代码一直和 ES 标准保持统一。
编译器更新其实值得,带来的兼容性只有两点:一是新的类型检查会暴露过去没有发现的错误,二是在同一个生态系统中如果各个项目使用不同版本的编译器,可能产物不通用。
保持一致的 tsconfig 设置是非常重要的,但最终我们放弃了严格模式,选择牺牲灵活性来保持所有项目配置的一致性。
当设计以 tsconfig.a.json 配置编译的代码被配置了 tsconfig.b.json 的工具引用后就可能出问题。
Ambient Modules 特别之处在于,TypeScript 在发表声明时保持对修饰符的引用,从而避免将它们转化为相对路径。
我们的平台会确保在运行时中每个包只有一个版本的存在。我们希望对类型提供一种 “精确且唯一” 的定义,更好的反应运行时环境。
不可能实现。
TypeScript 仍无法知晓它生成的文件中是否存在不安全的导入
因为从某个库的私有包中引入类型是不安全的,这种引入不受库接口的兼容性限制。
有时 TypeScript 会将依赖中的类型内联传递给当前的类型。我们见过一些极端的例子,由于这些重复的类型定义,声明文件的大小从 7KB 膨胀到了 700KB。
如何防止类型内联:
- 使用 interface 代替 type。
- 对输出添加类型注释,显式类型注释可以强制指定引用的行为,防止内联
Type Shaking 有时会效果极为显著。我们曾经遇到过一些包中超过 90% 文件中有超过 90% 的类型定义行是可以去掉的。
“TypeScript 不值得!”前端框架 Svelte 作者宣布重构代码,反向迁移到 JavaScript 引争议
2023 年 5 月 9 日,Svelte 团队发布 TS to JSDoc Conversion 的 PR,讨论点在 JSDoc 对类型开发的友好程度不如 TS。作者 Rich Harris 表示原因比较复杂。
团队在 Sveltekit 中尝试这么做并取得了良好效果,所以想在 Svelte 4.0 也这么做。
这就没必要了,因为在构建应用程序的过程中,大家无论如何都需要构建的步骤。应用开发的重点在于优化代码、控制它的体量,并把一切都捆绑起来。但如果是想构建一个库,那我强烈建议改用 JSDoc。
如果是构建应用程序,没必要转向 JSDoc。
对 Svelte 而言,框架本身没有放弃类型安全;对开发者而言,能降低贡献门槛;对用户而言,代码直接指向源码方便调试,也助于能减小代码体积。
项目配置
compilerOptions.isolatedModules
许多编译工具如 Babel 不支持在“一个系统层面”角度理解类型。所以需要开启 isolatedModules。开启后,TS 会将每一个 TS 文件都视为独立的 Module,这需要项目使用 ES Module 语法,但同时,TS 会带来更严格的类型检测支持。
在 vite-based 项目中,这个选项应该且默认打开。
reference
项目引用允许将 TypeScript 程序构建成更小的部分,以缩短构建时间,在组件之间实施逻辑分离,并以新的、更好的方式组织您的代码。
{
"compilerOptions": {
"composite": true,
"outDir": "./dist"
},
"references": [
{ "path": "../other-project" }
]
}
编译配置示例?
完整的编译配置文档见:TypeScript Compiler Options
{
"compilerOptions": {
/* 基本选项 */
"target": "es5", // 指定 ECMAScript 目标版本: 'es3', 'es5', 'es2015', 'es2016', 'es2017', 'es2018', 'es2019', 'es2020', 'es2021', 'es2022', 'esnext' 或 'latest'
"module": "commonjs", // 决定输出的模块类型: 'commonjs', 'amd', 'system', 'umd' or 'es2015'
"lib": [], // 指定要包含在编译中的库文件
"allowJs": true, // 允许编译 javascript 文件
"checkJs": true, // 报告 javascript 文件中的错误
"jsx": "preserve", // 指定 jsx 代码的生成: 'preserve', 'react-native', or 'react'
"declaration": true, // 生成相应的 '.d.ts' 文件
"sourceMap": true, // 生成相应的 '.map' 文件
"outFile": "./", // 将输出文件合并为一个文件
"outDir": "./", // 指定输出目录
"rootDir": "./", // 用来控制输出目录结构 --outDir.
"removeComments": true, // 删除编译后的所有的注释
"noEmit": true, // 不生成输出文件
"importHelpers": true, // 从 tslib 导入辅助工具函数
"isolatedModules": true, // 将每个文件作为单独的模块 (与 'ts.transpileModule' 类似).
/* 严格的类型检查选项 */
"strict": true, // 启用所有严格类型检查选项
"noImplicitAny": true, // 在表达式和声明上有隐含的 any类型时报错
"strictNullChecks": true, // 启用严格的 null 检查
"noImplicitThis": true, // 当 this 表达式值为 any 类型的时候,生成一个错误
"alwaysStrict": true, // 以严格模式检查每个模块,并在每个文件里加入 'use strict'
/* 额外的检查 */
"noUnusedLocals": true, // 有未使用的变量时,抛出错误
"noUnusedParameters": true, // 有未使用的参数时,抛出错误
"noImplicitReturns": true, // 并不是所有函数里的代码都有返回值时,抛出错误
"noFallthroughCasesInSwitch": true, // 报告 switch 语句的 fallthrough 错误。(即,不允许 switch 的 case 语句贯穿)
/* 模块解析选项 */
"moduleResolution": "node", // 模块解析策略: 'node' (Node.js) or 'classic' (TypeScript pre-1.6)
"baseUrl": "./", // 用于解析非相对模块名称的基目录
"paths": {}, // 模块名到基于 baseUrl 的路径映射的列表
"rootDirs": [], // 根文件夹列表,其组合内容表示项目运行时的结构内容
"typeRoots": [], // 包含类型声明的文件列表
"types": [], // 需要包含的类型声明文件名列表
"allowSyntheticDefaultImports": true, // 允许从没有设置默认导出的模块中默认导入。
/* Source Map Options */
"sourceRoot": "./", // 指定调试器应该找到 TypeScript 文件而不是源文件的位置
"mapRoot": "./", // 指定调试器应该找到映射文件而不是生成文件的位置
"inlineSourceMap": true, // 生成单个 sourcemaps 文件,而不是将 sourcemaps 生成不同的文件
"inlineSources": true, // 将代码与 sourcemaps 生成到一个文件中,要求同时设置了 --inlineSourceMap 或 --sourceMap 属性
/* 其他选项 */
"experimentalDecorators": true, // 启用装饰器
"emitDecoratorMetadata": true // 为装饰器提供元数据的支持
}
}
应用
如何从 JavaScript 项目迁移到 TypeScript?
见:Migrating from JavaScript @TypeScript
如何使用 DefinitelyTyped?
DefinitelyTyped 是一个社区驱动的 TypeScript 类型定义仓库,提供了大量第三方库的类型定义文件。几乎排名前 90% 的旧 JavaScript 库的声明文件存在于这个仓库。安装也非常简单,以 lodash 为例:
npm install --save-dev @types/lodash
相关:How Microsoft manage the Definitely Typed Repo
省略参数结合 Rest 参数的一个误用?
以下代码为例,调用函数 c 这种写法存在类型错误。
const c = (() => {}) as any as {
(foo: string, bar?: number, ...others: string[]): number;
}
//@ts-expect-error
c('123', '23')
除非改成重载类型:
const c = (() => {}) as any as {
(foo: string, bar?: number, ...others: string[]): number;
(foo: string, ...others: string[]): number;
}
如何校验递归类型?
可以借助类型保护函数校验递归类型,以下代码为例:
type Nested = { value: number } | { nested: Nested }
function isNested(obj: any): obj is { nested: Nested } {
return obj && typeof obj === 'object' && 'nested' in obj
}
function getValue(obj: Nested): number {
if ('value' in obj) {
return obj.value
}
if (isNested(obj)) {
return getValue(obj.nested)
}
throw new Error('Invalid structure')
}
只读索引签名是什么意思?
当使用只读索引签名去约定一个对象的类型时,只能向该对象添加新属性,而不能修改或删除这些属性。
interface Foo {
readonly [x: number]: number
}
const foo: Foo = { 0: 123, 2: 345 }
//@ts-expect-error
foo[0] = 456
//@ts-expect-error
delete foo[0]
如何给数组同时设置数字索引类型和字符串索引类型?
同时设置需要保证:number 索引返回的类型必须是 string 索引返回类型的子类型。比如,以下代码是正确的。
type Foo = {
[index: number]: { b: string }
[index: string]: { a?: string, b: string }
}
应当如何正确使用 never?
never 是一种底层类型,表示永远不会有值的类型。它通常用于函数永远不会返回的情况,比如抛出异常或无限循环。具体代码中,常使用 never 手动收缩类型,来达到类型保护的效果。
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
// 当新增一种 Shape 时,由于 getArea 中没有处理这种情况,
// 会抛类型错误,但不会影响运行时
//| { kind: 'new'; side: number }
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2
case 'square':
return shape.side ** 2
default:
const _never: never = shape
return _never
}
}
上述风格的写法也可以应用到代码的 if-else 分支中,确保所有可能的分支都被处理。
如何通过名义化类型解决结构化类型自动兼容问题?
名义化类型是指通过某种方式将类型的结构与其名称绑定在一起,从而使得两个结构相同但名称不同的类型不再兼容。
interface UserIdBrand { readonly __brand: "UserId" }
type UserId = string & UserIdBrand
interface ProductIdBrand { readonly __brand: "ProductId" }
type ProductId = string & ProductIdBrand
declare const userId: UserId
declare const productId: ProductId
getUserPosts(userId) // OK
getUserPosts(productId) // Type Error! 不能传 ProductId