📚 TypeScript 教程系列
入门与配置 基础类型与变量声明 函数 流程控制与运算符 集合类型 异步编程与错误处理 接口与类 泛型与类型组合 高级类型 (本文)模块、装饰器与工程化 TypeScript 与 Bun 实战 TypeScript 与 React 实战 ⚠️ 来源声明 :本文内容参考自 菜鸟教程 TypeScript 教程 ,仅供学习交流,版权归原作者所有。 TypeScript 的类型系统不仅能描述数据的形状,还能像写逻辑一样对类型本身进行运算。工具类型、条件类型、映射类型、infer、索引类型、递归类型,以及协变与逆变,构成了 TS 类型编程最核心也最硬核的部分。掌握它们,才能真正写出既类型安全又高度复用的代码。
工具类型 工具类型(Utility Types)是 TypeScript 内置的一系列泛型类型,本质上是基于映射类型和条件类型实现的「类型转换函数」。它们帮助开发者快速从一个已有类型派生出新类型,提升代码的复用性与类型安全性。
在实际开发中,我们经常需要基于现有类型创建变体:把所有属性变成可选、变成只读、只取其中几个字段、去掉某些字段等等。手动重写这些类型既繁琐又容易和原类型脱节,工具类型提供了一种声明式的方式来完成这些转换。
下面以这个 User 接口作为贯穿示例:
1 2 3 4 5 6 interface User { id : number ; name : string ; email : string ; password : string ; }
Partial<T> — 全部可选 Partial<T> 将类型 T 的所有属性设置为可选,常用于对象的部分更新、表单数据等场景。
1 2 3 4 5 6 type PartialUser = Partial <User >;const user : PartialUser = { name : "Alice" };console .log ("部分用户: " + JSON .stringify (user));
运行结果:
Partial 的底层实现就是一个映射类型,给每个属性加上 ? 修饰符:
1 2 3 type Partial <T> = { [P in keyof T]?: T[P]; };
Required<T> — 全部必填 Required<T> 与 Partial 相反,将所有可选属性变为必填。它通过 -? 修饰符移除可选标记。
1 2 3 4 5 6 7 8 9 10 interface Config { host ?: string ; port ?: number ; }type RequiredConfig = Required <Config >;const config : RequiredConfig = { host : "localhost" , port : 8080 };console .log ("配置: " + JSON .stringify (config));
实现:
1 2 3 type Required <T> = { [P in keyof T]-?: T[P]; };
Readonly<T> — 全部只读 Readonly<T> 将所有属性设置为只读,创建后不允许修改。常用于配置对象、枚举映射等不希望被篡改的数据。
1 2 3 4 5 type ReadonlyUser = Readonly <User >;const user : ReadonlyUser = { id : 1 , name : "Alice" , email : "a@b.com" , password : "secret" };console .log ("只读用户: " + JSON .stringify (user));
实现:
1 2 3 type Readonly <T> = { readonly [P in keyof T]: T[P]; };
Pick<T, K> — 选择属性 Pick<T, K> 从类型 T 中挑选指定的属性 K 组成新类型,适合只需要某个类型部分字段的场景。
1 2 3 4 5 type UserBasicInfo = Pick <User , "id" | "name" >;const user : UserBasicInfo = { id : 1 , name : "Alice" };console .log ("用户基本信息: " + JSON .stringify (user));
实现:
1 2 3 type Pick <T, K extends keyof T> = { [P in K]: T[P]; };
Omit<T, K> — 排除属性 Omit<T, K> 从类型 T 中排除指定的属性 K,返回剩余属性组成的新类型,是 Pick 的反向操作。需要排除少数字段时用 Omit,需要选择少数字段时用 Pick。
1 2 3 4 5 type UserWithoutPassword = Omit <User , "password" >;const user : UserWithoutPassword = { id : 1 , name : "Alice" , email : "a@b.com" };console .log ("无密码用户: " + JSON .stringify (user));
Omit 可以理解为先排除键、再 Pick:Pick<T, Exclude<keyof T, K>>。
Record<K, T> — 构造对象类型 Record<K, T> 构造一个对象类型,键的类型为 K,值的类型为 T。常用于创建键值对映射、字典、权限表等。
1 2 3 4 5 6 7 8 9 10 11 12 type Role = "admin" | "user" | "guest" ;type RolePermissions = Record <Role , string []>;const permissions : RolePermissions = { admin : ["read" , "write" , "delete" ], user : ["read" , "write" ], guest : ["read" ] };console .log ("管理员权限: " + permissions.admin );console .log ("访客权限: " + permissions.guest );
运行结果:
1 2 管理员权限: read,write,delete 访客权限: read
Record 会强制要求所有键都存在,漏写任意一个键都会报错。
Exclude<T, U> — 排除类型 Exclude<T, U> 从联合类型 T 中排除可以赋值给 U 的成员。原理是分布式条件类型:T extends U ? never : T。
1 2 3 4 5 type T = "a" | "b" | "c" | "d" ;type NonABC = Exclude <T, "a" | "b" | "c" >; const value : NonABC = "d" ;console .log ("值: " + value);
Extract<T, U> 与 Exclude 相反,从联合类型 T 中提取可以赋值给 U 的成员。
1 2 3 4 5 type T = "a" | "b" | "c" | 1 | 2 | 3 ;type Letters = Extract <T, string >; const letter : Letters = "a" ;console .log ("字母: " + letter);
Exclude 和 Extract 是互补的一对:Extract<T, U> 等价于从 T 中取出与 U 重叠的部分,Exclude<T, U> 则是去掉这部分。
NonNullable<T> — 排除空值 NonNullable<T> 从类型 T 中排除 null 和 undefined,等价于 Exclude<T, null | undefined>。
1 2 3 4 5 6 7 type T = string | null | undefined | number ;type NotNull = NonNullable <T>; let value : NotNull = "hello" ; value = 42 ;console .log ("值: " + value);
ReturnType<T> — 获取返回类型 ReturnType<T> 获取函数类型 T 的返回值类型,常用于从已有函数反向推导返回类型。其核心是 infer 关键字。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function getUser ( ) { return { name : "Alice" , age : 25 }; }function getConfig ( ) { return { host : "localhost" , port : 8080 }; }type UserType = ReturnType <typeof getUser>; type ConfigType = ReturnType <typeof getConfig>; const user : UserType = { name : "Bob" , age : 30 };const config : ConfigType = { host : "example.com" , port : 3000 };console .log ("用户: " + JSON .stringify (user));console .log ("配置: " + JSON .stringify (config));
实现:
1 2 type ReturnType <T extends (...args : any ) => any > = T extends (...args : any ) => infer R ? R : any ;
Parameters<T> — 获取参数类型 Parameters<T> 以元组形式获取函数类型 T 的全部参数类型,同样依赖 infer。
1 2 3 4 5 6 7 8 9 function createUser (name : string , age : number , active : boolean ): User { return { id : 1 , name, email : "" , password : "" }; }type CreateUserParams = Parameters <typeof createUser>;const params : CreateUserParams = ["Alice" , 25 , true ];console .log ("参数: " + JSON .stringify (params));
实现:
1 2 type Parameters <T extends (...args : any ) => any > = T extends (...args : infer P) => any ? P : never ;
类似的还有 ConstructorParameters<T>(构造函数参数)、InstanceType<T>(实例类型)、Awaited<T>(递归解包 Promise)等,思路一致。
NoInfer<T> — 阻止类型推断 NoInfer<T> 是 TypeScript 5.4 引入的内置工具类型,它包装某个类型,使其不参与泛型参数的推断。典型场景:让函数的某个参数类型由其他参数决定,而非让它自身的字面量也参与推断、拓宽泛型参数。
1 2 3 4 5 6 7 8 9 10 11 function createBad<T>(value : T, items : T[]): T { return value; }const bad = createBad ("a" as const , ["b" as const ]); function create<T>(value : T, items : NoInfer <T>[]): T { return value; }const good = create ("a" as const , ["a" as const ]);
NoInfer 适合配置对象、默认值列表等场景:既保留类型安全校验,又避免次要参数把主参数的类型推断「带偏」。
工具类型速查表 工具类型 作用 示例结果 Partial<T>所有属性可选 { id?: number; name?: string; ... }Required<T>所有属性必填 { host: string; port: number; }Readonly<T>所有属性只读 { readonly id: number; ... }Pick<T, K>选择指定属性 { id: number; name: string; }Omit<T, K>排除指定属性 { id: number; name: string; email: string; }Record<K, T>构造键值对类型 { admin: string[]; user: string[]; ... }Exclude<T, U>联合类型排除 "d"Extract<T, U>联合类型提取 "a" | "b" | "c"NonNullable<T>排除 null/undefined string | numberReturnType<T>函数返回类型 { name: string; age: number; }Parameters<T>函数参数元组 [string, number, boolean]NoInfer<T>阻止该参数参与推断 控制泛型推断方向
工具类型可以组合使用,例如 Partial<Readonly<T>> 表示「全部只读且全部可选」。如果内置工具类型不满足需求,也可以基于映射类型和条件类型自行实现。
satisfies 操作符 satisfies 操作符(TS 4.9 引入,早已是现代 TS 标准能力)用于在不丢失字面量类型信息的前提下校验一个值符合某类型。相比直接用 : Type 注解,它既做类型检查又保留推断出的字面量类型:
1 2 3 4 5 6 7 const paletteAnnotated : Record <string , string > = { red : "#f00" , green : "#0f0" };const palette = { red : "#f00" , green : "#0f0" } as const satisfies Record <string , string >;
satisfies 常与工具类型、条件类型配合:先用 satisfies 约束值的形状,再依赖保留的字面量类型做后续类型运算,是日常类型编程的高频写法。
条件类型 条件类型(Conditional Types)是 TypeScript 类型系统中最强大的特性之一,它允许根据条件动态选择类型,语法类似 JavaScript 的三元表达式:T extends U ? X : Y。如果类型 T 可以赋值给类型 U,则结果为 X,否则为 Y。条件类型是实现高级工具类型的基础。
基本语法 1 2 3 4 5 6 7 8 9 10 type IsString <T> = T extends string ? true : false ;type A = IsString <string >; type B = IsString <number >; const a : A = true ;const b : B = false ;console .log ("string 是字符串?: " + a);console .log ("number 是字符串?: " + b);
运行结果:
1 2 string 是字符串? : true number 是字符串? : false
条件类型在类型检查时会自动求值,生成具体的类型。它是延迟求值的——只有当传入具体类型时才会真正计算。
类型过滤 条件类型最常见的应用是过滤类型。例如实现一个 NonNullable,把 null 和 undefined 排除掉:
1 2 3 4 5 6 7 8 type MyNonNullable <T> = T extends null | undefined ? never : T;type A = MyNonNullable <string >; type B = MyNonNullable <null >; type C = MyNonNullable <undefined >; const a : A = "hello" ;console .log ("非空: " + a);
never 表示「永不存在的类型」,当条件不满足时用它来表示「这个分支不可用」,在联合类型中会被自动忽略。
分布式条件类型 当条件类型的泛型参数是裸的联合类型时,条件会自动「分布」到联合的每个成员上分别执行,再把结果合并。这是条件类型最容易被忽略、也最容易踩坑的特性。
1 2 3 4 5 6 7 8 9 10 type ToArray <T> = T extends any ? T[] : never ;type StrOrNum = ToArray <string | number >;const arr1 : StrOrNum = ["hello" ];const arr2 : StrOrNum = [1 , 2 , 3 ];console .log ("字符串数组: " + arr1);console .log ("数字数组: " + arr2);
注意结果是 string[] | number[],而不是 (string | number)[]。分布特性是自动开启的,如果想禁用分布、把联合类型作为整体处理,可以用方括号把 T 包起来:
1 2 3 4 5 6 7 type ToArrayNonDist <T> = [T] extends [any ] ? T[] : never ;type Combined = ToArrayNonDist <string | number >;const arr : Combined = ["hello" , 42 ];console .log ("混合数组: " + arr);
Exclude 和 Extract 之所以能逐个过滤联合成员,正是因为分布式条件类型。
infer 初探 条件类型中可以在 extends 右侧用 infer 声明一个待推断的类型变量,从而「提取」类型中的某个部分。最经典的例子就是 ReturnType:
1 2 3 4 5 6 7 8 9 type ReturnType <T> = T extends (...args : any []) => infer R ? R : never ;function getUser ( ) { return { name : "Alice" }; }type R1 = ReturnType <typeof getUser>; const r1 : R1 = { name : "Bob" };console .log ("用户: " + JSON .stringify (r1));
infer R 像类型系统里的「捕获变量」,把函数的返回类型抓住并在结果分支中使用。infer 的完整用法见后文。
条件类型与映射类型结合 条件类型常与映射类型组合,实现 Partial、Required、Readonly 等工具类型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 interface User { id : number ; name : string ; email : string ; }type Partial <T> = { [P in keyof T]?: T[P]; };type Required <T> = { [P in keyof T]-?: T[P]; };const partial : Partial <User > = { name : "Alice" };type RequiredUser = Required <Partial <User >>;const required : RequiredUser = { name : "Bob" , id : 1 , email : "bob@test.com" };console .log ("可选: " + JSON .stringify (partial));console .log ("必填: " + JSON .stringify (required));
运行结果:
1 2 可选: { "name" : "Alice" } 必填: { "name" : "Bob" , "id" : 1 , "email" : "bob@test.com" }
高级示例:类型检查 条件类型还能实现一些技巧性的类型检查。例如检测一个类型是否为 any——利用 any 与任何类型交叉都得 any、且 0 extends any 恒成立的特性:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 type IsAny <T> = 0 extends (1 & T) ? true : false ;type A = IsAny <any >; type B = IsAny <string >; type IsAssignableTo <T, U> = T extends U ? true : false ;type CanAssign = IsAssignableTo <string , any >; const a : A = true ;const b : B = false ;const canAssign : CanAssign = true ;console .log ("any 是 any?: " + a);console .log ("string 是 any?: " + b);console .log ("string 赋值给 any?: " + canAssign);
条件类型注意事项 延迟求值 :条件类型只有在传入具体类型时才求值,泛型参数未确定时保持未求值状态。分布特性 :裸联合类型会自动触发分布,用 [T] extends [U] 可禁用。infer 位置 :infer 只能出现在条件类型 extends 的右侧。配合映射类型 :大多数内置工具类型都是条件类型与映射类型的组合。映射类型 映射类型(Mapped Types)是一种基于已有类型批量创建新类型的语法,它用 keyof 取出所有键、用 in 遍历每个键,再对每个键应用统一的类型转换。把所有属性变成可选、变成只读、改键名、过滤键,都靠它完成。映射类型是 TypeScript 内置工具类型的核心实现技术。
工作原理 映射类型的基本语法是 [P in keyof T]: T[P],表示遍历 T 的每个键 P,值的类型取 T[P]。在此基础上可以加上修饰符:
修饰符 作用 典型应用 ?添加可选修饰 Partial<T>readonly添加只读修饰 Readonly<T>-?移除可选修饰 Required<T>-readonly移除只读修饰 Mutable<T>as重映射键名 key remapping
基础映射类型 下面手写一个 Partial,给所有属性加上 ?:
1 2 3 4 5 6 7 8 9 10 11 12 13 interface User { id : number ; name : string ; email : string ; }type MyPartial <T> = { [P in keyof T]?: T[P]; };type PartialUser = MyPartial <User >;const user : PartialUser = { name : "Alice" };console .log ("部分用户: " + JSON .stringify (user));
[P in keyof T] 表示遍历 T 的所有键,? 把每个属性都设为可选。
属性修饰符 通过 +/- 前缀可以添加或移除修饰符(+ 可省略)。下面演示只读、可选、必填三种变体:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 interface User { name : string ; age : number ; }type MyReadonly <T> = { readonly [P in keyof T]: T[P]; };type MyOptional <T> = { [P in keyof T]?: T[P]; };type MyRequired <T> = { [P in keyof T]-?: T[P]; };const readonlyUser : MyReadonly <User > = { name : "Alice" , age : 25 };const optionalUser : MyOptional <User > = { name : "Bob" };console .log ("只读: " + JSON .stringify (readonlyUser));console .log ("可选: " + JSON .stringify (optionalUser));
-? 移除可选修饰,-readonly 则可以移除只读修饰,把只读类型变回可写。
键名映射(as) TypeScript 支持在映射类型中用 as 子句重命名键。结合模板字面量类型与 Capitalize、Uncapitalize、Uppercase、Lowercase,能动态生成新键名:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 interface User { id : number ; name : string ; age : number ; }type WithPrefix <T, Prefix extends string > = { [P in keyof T as `${Prefix} ${Capitalize<string & P>} ` ]: T[P]; };type PrefixedUser = WithPrefix <User , "user" >;const user : PrefixedUser = { userId : 1 , userName : "Alice" , userAge : 25 };console .log ("带前缀: " + JSON .stringify (user));
运行结果:
1 带前缀: { "userId" : 1 , "userName" : "Alice" , "userAge" : 25 }
键过滤 在 as 子句中结合条件类型,可以把不需要的键映射为 never,从而把它从结果类型中移除——这正是 Omit 的一种实现方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 interface User { id : number ; name : string ; password : string ; email : string ; }type MyOmit <T, K extends keyof T> = { [P in keyof T as P extends K ? never : P]: T[P]; };type UserWithoutPassword = MyOmit <User , "password" >;const user : UserWithoutPassword = { id : 1 , name : "Alice" , email : "a@b.com" };console .log ("无密码: " + JSON .stringify (user));
当 P extends K 成立时键名变成 never,该属性会被完全移除。
条件映射 映射类型的值也可以是条件类型,根据每个属性原类型做不同处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 interface APIResponse { data : string ; error : string ; isLoading : boolean ; timestamp : number ; }type FunctionToVoid <T> = { [P in keyof T]: T[P] extends (...args : any []) => any ? () => void : T[P]; };const response : FunctionToVoid <APIResponse > = { data : "hello" , error : "" , isLoading : false , timestamp : Date .now () };console .log ("响应: " + JSON .stringify (response));
这种模式常用于处理 API 响应、清理配置对象等需要按类型分别处理的场景。
内置映射类型一览 1 2 3 4 5 6 7 8 9 10 11 12 13 14 type P1 = Partial <{ a : string ; b : number }>;type R1 = Required <{ a ?: string ; b ?: number }>;type RO1 = Readonly <{ a : string ; b : number }>;type PK = Pick <{ a : string ; b : number ; c : boolean }, "a" | "b" >;type OM = Omit <{ a : string ; b : number ; c : boolean }, "c" >;
这些内置工具类型都是基于映射类型和条件类型实现的,理解其原理才能灵活自定义。
映射类型注意事项 keyof 获取一个类型的所有键组成的联合类型。in 用于遍历键名联合类型。? 和 readonly 写在属性名前表示添加修饰,加 - 表示移除。as 子句用于重映射键名,必须返回字符串或数字字面量类型。映射类型可与条件类型、模板字面量类型组合,实现复杂的类型转换。 infer 关键字 infer 是条件类型中的关键字,意为「推断」,它只能在条件类型的 extends 子句中使用,用于声明一个待推断的类型变量。infer 让我们能从复杂类型中优雅地「拆」出部分类型:从 Promise<string> 取出 string、从 Array<User> 取出 User、从函数取出返回类型或参数类型,全靠它。
基本用法:Promise 解包 下面从 Promise<V> 中提取值类型 V:
1 2 3 4 5 6 7 8 type ValueOf <T> = T extends Promise <infer V> ? V : never ;type Str = ValueOf <Promise <string >>; type Num = ValueOf <Promise <number >>; type NotPrm = ValueOf <string >; const value : Str = "world" ;console .log ("字符串提取: " + (typeof value === "string" ? "成功" : "失败" ));
infer V 声明了一个类型变量 V,TypeScript 会根据 T extends Promise<V> 这个约束自动推断 V 的类型;如果 T 根本不是 Promise,则走 never 分支。
提取数组元素类型 用 infer V 配合数组模式,可以提取元素类型:
1 2 3 4 5 6 7 8 9 10 11 12 type ArrayElement <T> = T extends (infer V)[] ? V : never ;type User = { name : string };type Users = User [];type E1 = ArrayElement <string []>; type E2 = ArrayElement <Users >; type E3 = ArrayElement <number >; const users : Users = [{ name : "Alice" }];const element : ArrayElement <Users > = users[0 ];console .log ("元素类型: " + element.name );
提取函数返回类型 从函数类型中提取返回类型——这正是内置 ReturnType 的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 type MyReturnType <T> = T extends (...args : any []) => infer R ? R : any ;function getData ( ) { return { id : 1 , name : "Alice" }; }function fetchUser (id : number ): Promise <{ id : number ; name : string }> { return Promise .resolve ({ id, name : "Bob" }); }type R1 = MyReturnType <typeof getData>; type R2 = MyReturnType <typeof fetchUser>; const result : R1 = { id : 1 , name : "Test" };console .log ("返回类型: " + JSON .stringify (result));
提取函数参数类型 用 infer P 捕获参数元组,就能实现 Parameters。也可以只取第一个参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 type MyParameters <T> = T extends (...args : infer P) => any ? P : never ;type FirstParameter <T> = T extends (first : infer P, ...rest : any []) => any ? P : never ;function createUser (name : string , age : number ): { name : string } { return { name }; }function logMessage (msg : string ): void { console .log (msg); }type AllParams = MyParameters <typeof createUser>; type P1 = FirstParameter <typeof createUser>; type P2 = FirstParameter <typeof logMessage>; type P3 = FirstParameter <() => void >; const nameParam : P1 = "Alice" ;console .log ("参数类型: " + nameParam);
...rest: any[] 用来匹配任意数量的剩余参数,非常实用。
多个 infer 一个条件类型中可以同时声明多个 infer 变量。下面提取元组的前两个元素类型,以及带 value 属性的对象的 value 类型:
1 2 3 4 5 6 7 8 9 10 11 12 type FirstTwo <T> = T extends [infer A, infer B, ...rest : any []] ? [A, B] : never ;type Tuple = [string , number , boolean ];type FirstTwoTypes = FirstTwo <Tuple >; type ObjectValue <T> = T extends { value : infer V } ? V : never ;type WithValue = { value : string ; name : string };type ExtractedValue = ObjectValue <WithValue >; const tupleResult : FirstTwoTypes = ["hello" , 123 ];console .log ("元组: " + JSON .stringify (tupleResult));
在递归类型中使用 infer infer 与递归条件类型结合,可以实现深度类型转换。下面实现一个递归解包 Promise 的类型,把 Promise<Promise<string>> 拍平成 string:
1 2 3 4 5 6 7 8 9 10 11 type FlattenPromise <T> = T extends Promise <infer U> ? U extends Promise <any > ? FlattenPromise <U> : U : T;type Nested = Promise <Promise <string >>;type Flat = FlattenPromise <Nested >; const flat : Flat = "done" ;console .log ("扁平化: " + flat);
内置的 Awaited<T> 就是这种递归 infer 的实现。递归 infer 还能实现深度只读、深度可选等工具类型,详见后文「递归类型」。
const 类型参数:控制推断宽窄 infer 是在条件类型里「提取」类型,而 const 类型参数(TS 5.0)则是在调用端「控制」推断的宽窄。给类型参数加上 const 修饰符后,TypeScript 会按字面量而非拓宽后的类型来推断:
1 2 3 4 5 6 7 8 9 10 11 function box<T>(x : T): T[] { return [x]; }const a = box ("hello" ); function boxConst<const T>(x : T): T[] { return [x]; }const b = boxConst ("hello" );
const 类型参数与本节的 infer 一脉相承——都是精细控制类型推断的方向与宽窄,只是作用点不同:infer 在类型层面提取,const 在调用层面收紧。
infer 注意事项 infer 只能用在条件类型 extends 的右侧。用 infer V 声明待推断的类型变量,变量名任意。 一个条件类型中可以使用多个 infer。 推断失败时走 false 分支,通常返回 never。 infer 是实现自定义工具类型的核心,掌握它可以写出非常强大的类型操作。💡 TS 5.x 的窄化增强 :infer 与 const 类型参数都属于类型层面的推断;在值层面,TypeScript 5.x 也持续强化窄化——5.4 起在闭包(回调、setTimeout 等)中保留此前的窄化结果,5.5 起更能为返回 typeof x === "string" 这类判断的函数自动推断类型谓词(x is string),无需手写谓词即可获得窄化能力。
索引类型与 keyof 索引类型(Indexed Types)和 keyof 是操作对象类型的利器,它们允许在类型系统中动态地访问对象属性,并创建灵活的类型映射。keyof 取键、T[K] 取值类型,是通用工具类型的基石。
keyof 操作符 keyof 用于获取对象类型所有键组成的字面量联合类型。注意返回的是键名的字面量联合,而不是 string。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 interface User { id : number ; name : string ; email : string ; age ?: number ; }type UserKeys = keyof User ;function getProperty<T, K extends keyof T>(obj : T, key : K): T[K] { return obj[key]; }const user : User = { id : 1 , name : "Alice" , email : "alice@example.com" };const userName : string = getProperty (user, "name" );console .log ("用户名: " + userName);
K extends keyof T 这个约束非常关键:它确保传入的键一定是对象上真实存在的键,从而把「访问不存在的属性」这类运行时错误提前到编译期。
索引访问类型 用方括号 [] 可以访问对象类型某个属性的类型,就像在运行时用 obj[key] 访问值一样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 interface User { id : number ; name : string ; email : string ; }type UserId = User ["id" ]; type UserName = User ["name" ]; type UserIdAndName = User ["id" | "name" ]; type AllUserValues = User [keyof User ]; function getValue<T, K extends keyof T>(obj : T, key : K): T[K] { return obj[key]; }const user : User = { id : 1 , name : "Bob" , email : "bob@test.com" };const idValue : number = getValue (user, "id" );const nameValue : string = getValue (user, "name" );console .log ("ID: " + idValue);console .log ("Name: " + nameValue);
约束键的类型 K extends keyof T 是通用 getProperty 函数的核心,它让键参数在编译期就被校验:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 interface Config { apiUrl : string ; timeout : number ; retry : boolean ; }function getConfigValue<T, K extends keyof T>(config : T, key : K): T[K] { return config[key]; }const config : Config = { apiUrl : "https://api.example.com" , timeout : 5000 , retry : true };const url : string = getConfigValue (config, "apiUrl" );const timeoutVal : number = getConfigValue (config, "timeout" );console .log ("API URL: " + url);console .log ("Timeout: " + timeoutVal);
返回类型 T[K] 也会随键变化——传 "apiUrl" 返回 string,传 "timeout" 返回 number,完全类型安全。
只获取特定类型的属性 结合映射类型与条件类型,可以提取「值类型满足某条件」的键。下面提取所有 string 类型和所有 number 类型的属性键:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 interface Mixed { id : number ; name : string ; age : number ; email : string ; active : boolean ; }type StringKeys <T> = { [K in keyof T]: T[K] extends string ? K : never ; }[keyof T];type NumberKeys <T> = { [K in keyof T]: T[K] extends number ? K : never ; }[keyof T];type StringProps = StringKeys <Mixed >; type NumberProps = NumberKeys <Mixed >; function getStringProps<T, K extends StringKeys <T>>(obj : T, keys : K[]): T[K][] { return keys.map (key => obj[key]); }const mixed : Mixed = { id : 1 , name : "Alice" , age : 25 , email : "alice@test.com" , active : true };const strings = getStringProps (mixed, ["name" , "email" ]);console .log ("字符串属性: " + strings.join (", " ));
这种「映射成 K | never 再 [keyof T] 取值」的技巧,是按值类型筛选键的标准范式。
遍历数组与元组类型 索引访问同样适用于数组和元组。元组可以用数字字面量索引取到精确的元素类型,数组用 number 索引取到元素类型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 type Tuple = [string , number , boolean ];type First = Tuple [0 ]; type Second = Tuple [1 ]; type Third = Tuple [2 ]; type AllElements = Tuple [number ]; type StringArray = string [];type ArrayElement = StringArray [number ]; function getElement<T extends any []>(arr : T, index : number ): T[number ] | undefined { return index < arr.length ? arr[index] : undefined ; }const tuple : Tuple = ["hello" , 123 , true ];const arr : string [] = ["a" , "b" , "c" ];console .log ("元组元素[0]: " + getElement (tuple, 0 ));console .log ("数组元素[1]: " + getElement (arr, 1 ));
元组能按位置精确取类型,这是普通数组做不到的。
索引类型注意事项 keyof 返回键名的字面量联合类型。用 T[K] 做索引访问时,确保 K 存在于目标类型中。 用 K extends keyof T 约束泛型参数,可实现类型安全的动态属性访问。 映射类型用 [P in keyof T] 语法遍历键。 索引类型是创建通用工具类型的基石,熟练掌握能大幅提升类型操作能力。 递归类型 递归类型(Recursive Types)是指在类型定义中引用自身的类型,它可以表达任意深度的嵌套结构,是处理树形数据、嵌套对象、深度类型转换的基石。现实中的文件系统、组织架构、JSON 数据都是天然递归的,递归类型让我们能在类型层面精确描述它们。
树形结构 递归类型最经典的应用是树形结构。下面定义一个 TreeNode,它的 children 又是 TreeNode[]:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 interface TreeNode { id : number ; name : string ; children ?: TreeNode []; }const fileSystem : TreeNode = { id : 1 , name : "根目录" , children : [ { id : 2 , name : "文件夹1" , children : [ { id : 5 , name : "文件A.txt" }, { id : 6 , name : "文件B.txt" } ] }, { id : 3 , name : "文件夹2" , children : [ { id : 7 , name : "文件C.txt" } ] }, { id : 4 , name : "文件.txt" } ] };function traverse (node : TreeNode , depth : number = 0 ): void { const indent = " " .repeat (depth); console .log (indent + "- " + node.name ); if (node.children ) { for (const child of node.children ) { traverse (child, depth + 1 ); } } }traverse (fileSystem);
运行结果:
1 2 3 4 5 6 7 - 根目录 - 文件夹1 - 文件A. txt - 文件B. txt - 文件夹2 - 文件C. txt - 文件. txt
类型是递归的,遍历函数自然也是递归的,两者结构一致。
链式数据结构:链表 链表是另一种线性递归结构,每个节点通过 next 指向下一个同类型节点:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 interface ListNode <T> { value : T; next ?: ListNode <T>; }const linkedList : ListNode <number > = { value : 1 , next : { value : 2 , next : { value : 3 , next : { value : 4 , next : undefined } } } };function traverseList<T>(node : ListNode <T>): void { let current : ListNode <T> | undefined = node; const values : T[] = []; while (current) { values.push (current.value ); current = current.next ; } console .log ("链表值: " + values.join (" -> " )); }function getLength<T>(node : ListNode <T>): number { let length = 0 ; let current : ListNode <T> | undefined = node; while (current) { length++; current = current.next ; } return length; }traverseList (linkedList);console .log ("链表长度: " + getLength (linkedList));
嵌套列表 递归联合类型可以表示「元素或元素列表」这种混合结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 type NestedList <T> = T | NestedList <T>[];interface Task { id : number ; title : string ; completed : boolean ; }const tasks : NestedList <Task > = [ { id : 1 , title : "项目A" , completed : false }, [ { id : 2 , title : "子任务1" , completed : true }, { id : 3 , title : "子任务2" , completed : false } ], { id : 4 , title : "项目B" , completed : false } ];function getDepth<T>(list : NestedList <T>, depth : number = 0 ): number { if (Array .isArray (list)) { let maxDepth = depth + 1 ; for (const item of list) { maxDepth = Math .max (maxDepth, getDepth (item, depth + 1 )); } return maxDepth; } return depth; }console .log ("列表深度: " + getDepth (tasks));
T | NestedList<T>[] 让一个值既可以是单个 T,也可以是嵌套的 T 列表,递归到底。
JSON 类型 JSON 规范本身是递归的:一个 JSON 值可以是基本类型、数组或对象,而数组元素和对象值又都是 JSON 值。用递归类型可以精确表达:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 type JSON Value = | string | number | boolean | null | JSON Value[] | { [key : string ]: JSON Value };const config : JSON Value = { "name" : "my-app" , "version" : "1.0.0" , "enabled" : true , "settings" : { "debug" : false , "ports" : [3000 , 8080 ], "metadata" : { "author" : "Alice" , "tags" : ["web" , "typescript" ] } } };function getValue (obj : JSON Value, path : string ): JSON Value | undefined { const keys = path.split ("." ); let current : JSON Value | undefined = obj; for (const key of keys) { if (current && typeof current === "object" && !Array .isArray (current)) { current = (current as { [key : string ]: JSON Value })[key]; } else { return undefined ; } } return current; }console .log ("版本: " + getValue (config, "version" ));console .log ("端口: " + getValue (config, "settings.ports" ));console .log ("作者: " + getValue (config, "settings.metadata.author" ));
深度只读类型 递归类型配合映射类型,可以实现深度类型转换。下面实现 DeepReadonly,递归地把所有嵌套对象属性变成只读,但跳过函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 type DeepReadonly <T> = T extends Function ? T : T extends object ? { readonly [P in keyof T]: DeepReadonly <T[P]> } : T;interface User { name : string ; profile : { email : string ; address : { city : string ; zip : string ; }; }; friends : User []; }const user : DeepReadonly <User > = { name : "Alice" , profile : { email : "alice@test.com" , address : { city : "Beijing" , zip : "100000" } }, friends : [] };console .log ("用户: " + user.name );console .log ("城市: " + user.profile .address .city );
深度可选类型 同理可以实现 DeepPartial,递归地把所有层级属性变为可选,便于处理「只提供部分嵌套配置」的场景:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 type DeepPartial <T> = T extends object ? { [P in keyof T]?: DeepPartial <T[P]> } : T;interface AppConfig { database : { host : string ; port : number ; credentials : { username : string ; password : string ; }; }; server : { port : number ; ssl : boolean ; }; }const partialConfig : DeepPartial <AppConfig > = { database : { host : "localhost" } };console .log ("数据库主机: " + partialConfig.database ?.host );
递归类型注意事项 递归类型必须保证有终止条件(如 T extends object ? ... : T),避免无限递归。 递归通常与条件类型结合,用条件判断来终止递归。 TypeScript 编译器对递归深度有限制,过深的递归会报错。 深度递归可能影响类型检查性能,按需使用。 协变与逆变 协变(Covariance)与逆变(Contravariance)描述的是:当存在子父类型关系时,泛型类型参数如何随之变化。它们决定了「函数 A 能否赋值给函数 B」这类兼容性问题,是编写类型安全泛型 API 的理论基础。
基本概念 先看一张总览表:
概念 方向 适用场景 示例 协变(Covariant) 子类型 → 父类型 输出(返回值) Provider<Dog> → Provider<Animal>逆变(Contravariant) 父类型 → 子类型 输入(参数) Consumer<Animal> → Consumer<Dog>不变(Invariant) 不可相互赋值 既输入又输出 Consumer<Dog> ≠ Consumer<Animal>TS 默认行为 — — 返回值协变;参数逆变(strictFunctionTypes);属性协变
直觉上:Dog 是 Animal 的子类型,把 Dog 当 Animal 用是安全的;但「处理 Animal 的函数」和「处理 Dog 的函数」之间的关系要反过来想——这就是协变与逆变要解决的问题。
协变:输出类型 协变是指子类型可以赋值给父类型。对于输出类型(如函数返回值),这是安全的:返回 Dog 的函数,当然可以当作返回 Animal 的函数来用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class Animal { name : string = "动物" ; }class Dog extends Animal { breed : string = "田园犬" ; }type AnimalGetter = () => Animal ;type DogGetter = () => Dog ;const getDog : DogGetter = () => new Dog ();const getAnimal : AnimalGetter = getDog; const animal : Animal = getAnimal ();console .log ("动物名称: " + animal.name );
运行结果:
返回更具体的子类型总是符合父类型的契约,所以协变对返回值是安全的。
逆变:输入类型 逆变是指对于输入类型(如函数参数),父类型可以赋值给子类型。道理是:一个能处理所有 Animal 的函数,当然也能处理 Dog(因为 Dog 就是 Animal),所以「接受 Animal 的函数」可以赋值给「接受 Dog 的函数」。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class Animal { name : string = "动物" ; }class Dog extends Animal { breed : string = "田园犬" ; }type DogConsumer = (dog : Dog ) => void ;type AnimalConsumer = (animal : Animal ) => void ;const consumeAnimal : AnimalConsumer = (animal ) => { console .log ("处理动物: " + animal.name ); };const consumeDog : DogConsumer = consumeAnimal; const dog = new Dog (); dog.breed = "哈士奇" ;consumeDog (dog);
反过来则不安全:如果把「接受 Dog 的函数」赋值给「接受 Animal 的函数」,调用时传入一个非 Dog 的 Animal(比如 Cat),函数内部访问 breed 就会出错。所以参数方向必须逆变。
启用严格函数类型 TypeScript 默认对方法参数采用双变(bivariant)检查(为了兼容数组方法等场景),但在开启 strictFunctionTypes 后,函数参数会严格按逆变检查。建议在 tsconfig.json 中开启此选项以获得更严格的类型保护。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 interface Animal { readonly name : string ; }interface Dog extends Animal { readonly breed : string ; }type GetName = (animal : Animal ) => string ;type GetDogBreed = (dog : Dog ) => string ;const getDogBreed : GetDogBreed = (dog ) => dog.breed ;function printAnimalName (animal : Animal ): string { return animal.name ; }const fn1 : GetDogBreed = (dog : Animal ) => dog.name ; console .log ("犬种: " + getDogBreed ({ name : "旺财" , breed : "哈士奇" }));
泛型类的协变 泛型容器的属性默认是协变的——Cage<Dog> 可以赋值给 Cage<Animal>:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Animal { name : string = "动物" ; }class Dog extends Animal { breed : string = "狗" ; }class Cage <T> { animal : T; constructor (animal : T ) { this .animal = animal; } }const dogCage = new Cage (new Dog ());const animalCage : Cage <Animal > = dogCage; console .log ("动物名称: " + animalCage.animal .name );
数组的协变 TypeScript 中数组是协变的,Dog[] 可以赋值给 Animal[]。但这在理论上并不完全安全——如果通过 Animal[] 引用 push 一个非 Dog 的 Animal,原 Dog[] 就被污染了。TypeScript 允许这种协变主要是为了和 JavaScript 的动态数组行为兼容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Animal { name : string = "动物" ; }class Dog extends Animal { breed : string = "狗" ; }const dogs : Dog [] = [ { name : "旺财" , breed : "哈士奇" }, { name : "小白" , breed : "萨摩耶" } ];const animals : Animal [] = dogs; console .log ("动物数量: " + animals.length );
所以对数组协变要心中有数:读取是安全的,写入需谨慎。
用接口设计安全的协变/逆变 理解了规则,就可以在泛型接口里明确「生产者协变、消费者逆变」。下面用 Producer<T>(只产出 T,协变)和 Consumer<T>(只消费 T,逆变)来建模:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 interface Producer <T> { produce (): T; }interface Consumer <T> { consume (value : T): void ; }class DogProducer implements Producer <Dog > { produce (): Dog { return { name : "旺财" , breed : "哈士奇" }; } }class AnimalConsumerImpl implements Consumer <Animal > { consume (animal : Animal ): void { console .log ("消费动物: " + animal.name ); } }const animalProducer : Producer <Animal > = new DogProducer ();const dogConsumer : Consumer <Dog > = new AnimalConsumerImpl ();const animal = animalProducer.produce ();console .log ("生产: " + animal.name ); dogConsumer.consume ({ name : "旺财" , breed : "哈士奇" });
这正是「PECS 原则」(Producer Extends, Consumer Super)在 TypeScript 中的体现:生产者用协变方向,消费者用逆变方向,API 的类型安全性才能落到实处。
协变与逆变注意事项 返回值协变 :函数返回子类型是安全的。参数逆变 :函数参数用父类型是安全的(strictFunctionTypes 下强制)。不变 :既读又写的泛型容器(如可变 List<T>)不能相互赋值。数组协变 :读取安全,写入有隐患。设计 API :根据方法是「产出」还是「消费」选择类型方向,能显著提升类型安全性。小结 本篇把 TypeScript 类型系统最硬核的七块拼到了一起:
工具类型 :Partial/Required/Readonly/Pick/Omit/Record/Exclude/Extract/NonNullable/ReturnType/Parameters 等内置泛型,是日常最常用的类型转换工具。条件类型 :T extends U ? X : Y 让类型能做逻辑判断,分布式特性是过滤联合类型的关键。映射类型 :[P in keyof T] 批量改属性,配合 +/- 修饰符和 as 重映射,是工具类型的底层实现。infer :在条件类型中捕获并推断类型片段,是提取函数返回/参数、数组元素、Promise 值的核心。索引类型 :keyof 取键、T[K] 取值类型,让动态属性访问保持类型安全。递归类型 :自引用类型描述树、链表、JSON 等嵌套结构,配合条件类型实现深度转换。协变与逆变 :揭示函数参数兼容性的本质,指导泛型 API 的方向设计。这些特性往往组合使用:用 keyof 和映射类型写工具类型,用条件类型做分支,用 infer 提取片段,用递归处理嵌套,用协变/逆变约束方向。把它们融会贯通,就能写出既类型安全又高度复用的代码,真正发挥 TypeScript 类型系统的威力。