📚 TypeScript 教程系列

  1. 入门与配置
  2. 基础类型与变量声明
  3. 函数
  4. 流程控制与运算符
  5. 集合类型
  6. 异步编程与错误处理
  7. 接口与类
  8. 泛型与类型组合
  9. 高级类型(本文)
  10. 模块、装饰器与工程化

⚠️ 来源声明:本文内容参考自 菜鸟教程 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>;
// 等价于 { id?: number; name?: string; email?: string; password?: string; }

// 只需要提供部分属性
const user: PartialUser = { name: "Alice" };
console.log("部分用户: " + JSON.stringify(user));

运行结果:

1
部分用户: {"name":"Alice"}

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>;
// 等价于 { host: string; port: number; }

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" };
// user.name = "Bob"; // 错误:只读属性不能修改
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">;
// 等价于 { id: number; name: string; }

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">;
// 等价于 { id: number; name: string; email: string; }

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">; // "d"

const value: NonABC = "d";
console.log("值: " + value);

Extract<T, U> — 提取类型

Extract<T, U>Exclude 相反,从联合类型 T 中提取可以赋值给 U 的成员。

1
2
3
4
5
type T = "a" | "b" | "c" | 1 | 2 | 3;
type Letters = Extract<T, string>; // "a" | "b" | "c"

const letter: Letters = "a";
console.log("字母: " + letter);

ExcludeExtract 是互补的一对:Extract<T, U> 等价于从 T 中取出与 U 重叠的部分,Exclude<T, U> 则是去掉这部分。

NonNullable<T> — 排除空值

NonNullable<T> 从类型 T 中排除 nullundefined,等价于 Exclude<T, null | undefined>

1
2
3
4
5
6
7
type T = string | null | undefined | number;
type NotNull = NonNullable<T>; // string | number

let value: NotNull = "hello";
value = 42;
// value = null; // 错误:不能赋值 null
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>; // { name: string; age: number; }
type ConfigType = ReturnType<typeof getConfig>; // { host: string; port: number; }

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>;
// 等价于 [name: string, age: number, active: boolean]

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/undefinedstring | 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
// 如果 T 是字符串类型,返回 true,否则返回 false
type IsString<T> = T extends string ? true : false;

type A = IsString<string>; // true
type B = IsString<number>; // false

const a: A = true;
const b: B = false;
console.log("string 是字符串?: " + a);
console.log("number 是字符串?: " + b);

运行结果:

1
2
string 是字符串?: true
number 是字符串?: false

条件类型在类型检查时会自动求值,生成具体的类型。它是延迟求值的——只有当传入具体类型时才会真正计算。

类型过滤

条件类型最常见的应用是过滤类型。例如实现一个 NonNullable,把 nullundefined 排除掉:

1
2
3
4
5
6
7
8
type MyNonNullable<T> = T extends null | undefined ? never : T;

type A = MyNonNullable<string>; // string
type B = MyNonNullable<null>; // never
type C = MyNonNullable<undefined>; // never

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;

// string | number 会分布为:ToArray<string> | ToArray<number>
// 即 string[] | number[]
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;

// 不会分布,整体处理,结果是 (string | number)[]
type Combined = ToArrayNonDist<string | number>;

const arr: Combined = ["hello", 42];
console.log("混合数组: " + arr);

ExcludeExtract 之所以能逐个过滤联合成员,正是因为分布式条件类型。

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>; // { name: string }
const r1: R1 = { name: "Bob" };
console.log("用户: " + JSON.stringify(r1));

infer R 像类型系统里的「捕获变量」,把函数的返回类型抓住并在结果分支中使用。infer 的完整用法见后文。

条件类型与映射类型结合

条件类型常与映射类型组合,实现 PartialRequiredReadonly 等工具类型:

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>; // true
type B = IsAny<string>; // false

type IsAssignableTo<T, U> = T extends U ? true : false;
type CanAssign = IsAssignableTo<string, any>; // 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 4.1 引入了 as 子句,可以在映射时重命名键。结合模板字面量类型与 CapitalizeUncapitalizeUppercaseLowercase,能动态生成新键名:

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">;
// 等价于 { userId: number; userName: string; userAge: number; }

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 }>;
// { a?: string; b?: number; }

type R1 = Required<{ a?: string; b?: number }>;
// { a: string; b: number; }

type RO1 = Readonly<{ a: string; b: number }>;
// { readonly a: string; readonly b: number; }

type PK = Pick<{ a: string; b: number; c: boolean }, "a" | "b">;
// { a: string; b: number; }

type OM = Omit<{ a: string; b: number; c: boolean }, "c">;
// { a: string; b: number; }

这些内置工具类型都是基于映射类型和条件类型实现的,理解其原理才能灵活自定义。

映射类型注意事项

  • 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>>; // string
type Num = ValueOf<Promise<number>>; // number
type NotPrm = ValueOf<string>; // never

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[]>; // string
type E2 = ArrayElement<Users>; // User
type E3 = ArrayElement<number>; // never(非数组)

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>; // { id: number; name: string; }
type R2 = MyReturnType<typeof fetchUser>; // Promise<{ id: number; name: string; }>

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>; // [string, number]
type P1 = FirstParameter<typeof createUser>; // string
type P2 = FirstParameter<typeof logMessage>; // string
type P3 = FirstParameter<() => void>; // never

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>; // [string, number]

type ObjectValue<T> = T extends { value: infer V } ? V : never;

type WithValue = { value: string; name: string };
type ExtractedValue = ObjectValue<WithValue>; // string

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>; // string

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;
}

// 结果: "id" | "name" | "email" | "age"
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");
// getProperty(user, "unknown"); // 错误:键不存在
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"]; // number
type UserName = User["name"]; // string

// 用联合类型访问多个键,结果是属性类型的联合
type UserIdAndName = User["id" | "name"]; // number | string

// 用 keyof 访问所有值类型
type AllUserValues = User[keyof User]; // number | string

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");
// const invalid = getConfigValue(config, "unknown"); // 错误:键不存在
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;
}

// 先把每个键映射成「符合条件的键,否则 never」,再用索引取值得到联合
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>; // "name" | "email"
type NumberProps = NumberKeys<Mixed>; // "id" | "age"

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]; // string
type Second = Tuple[1]; // number
type Third = Tuple[2]; // boolean

// 用 number 取所有元素类型的联合
type AllElements = Tuple[number]; // string | number | boolean

// 数组用 number 取元素类型
type StringArray = string[];
type ArrayElement = StringArray[number]; // string

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: []
};

// user.name = "Bob"; // 错误:只读
// user.profile.address.city = "Shanghai"; // 错误:深层也是只读
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"
// port 和 credentials 可选
}
// server 整个可选
};

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);属性协变

直觉上:DogAnimal 的子类型,把 DogAnimal 用是安全的;但「处理 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);

运行结果:

1
动物名称: 动物

返回更具体的子类型总是符合父类型的契约,所以协变对返回值是安全的。

逆变:输入类型

逆变是指对于输入类型(如函数参数),父类型可以赋值给子类型。道理是:一个能处理所有 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;
}

// 严格模式下:参数是逆变的
// 把「接受更宽泛 Animal 的函数」赋值给「接受更具体 Dog 的函数」是允许的(逆变)
const fn1: GetDogBreed = (dog: Animal) => dog.name; // 实参收窄,安全

// 反过来把「接受 Dog 的函数」赋值给「接受 Animal 的函数」会报错
// const fn2: GetName = (dog: Dog) => dog.breed; // 错误:传入非 Dog 的 Animal 会崩

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; // 协变:编译期允许

// 注意:通过 animals push 非 Dog 的 Animal 会污染原 dogs 数组
// animals.push({ name: "猫咪" }); // 运行时可能出问题
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);
}
}

// Producer<Dog> 可以赋值给 Producer<Animal>(协变)
const animalProducer: Producer<Animal> = new DogProducer();

// Consumer<Animal> 可以赋值给 Consumer<Dog>(逆变)
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 类型系统的威力。