📚 TypeScript 教程系列
- 入门与配置
- 基础类型与变量声明
- 函数
- 流程控制与运算符
- 集合类型
- 异步编程与错误处理
- 接口与类
- 泛型与类型组合
- 高级类型(本文)
- 模块、装饰器与工程化
⚠️ 来源声明:本文内容参考自 菜鸟教程 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)等,思路一致。
工具类型速查表
| 工具类型 | 作用 | 示例结果 |
|---|
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 | number |
ReturnType<T> | 函数返回类型 | { name: string; age: number; } |
Parameters<T> | 函数参数元组 | [string, number, boolean] |
工具类型可以组合使用,例如 Partial<Readonly<T>> 表示「全部只读且全部可选」。如果内置工具类型不满足需求,也可以基于映射类型和条件类型自行实现。
条件类型
条件类型(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 };
console.log("可选: " + JSON.stringify(partial)); console.log("必填: " + JSON.stringify(required));
|
运行结果:
1 2
| 可选: {"name":"Alice"} 必填: {"name":"Bob","id":1}
|
高级示例:类型检查
条件类型还能实现一些技巧性的类型检查。例如检测一个类型是否为 any——利用 any 与任何类型交叉都得 any、且 0 extends any 恒成立的特性:
1 2 3 4 5 6 7 8 9 10 11
| 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>;
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 4.1 引入了 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 还能实现深度只读、深度可选等工具类型,详见后文「递归类型」。
infer 注意事项
infer 只能用在条件类型 extends 的右侧。- 用
infer V 声明待推断的类型变量,变量名任意。 - 一个条件类型中可以使用多个
infer。 - 推断失败时走
false 分支,通常返回 never。 infer 是实现自定义工具类型的核心,掌握它可以写出非常强大的类型操作。
索引类型与 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 JSONValue = | string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue };
const config: JSONValue = { "name": "my-app", "version": "1.0.0", "enabled": true, "settings": { "debug": false, "ports": [3000, 8080], "metadata": { "author": "Alice", "tags": ["web", "typescript"] } } };
function getValue(obj: JSONValue, path: string): JSONValue | undefined { const keys = path.split("."); let current: JSONValue | undefined = obj; for (const key of keys) { if (current && typeof current === "object" && !Array.isArray(current)) { current = (current as { [key: string]: JSONValue })[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 类型系统的威力。