高级类型

2026-09-09 00:00    #typescript  

前四组讲的是"怎么描述一个类型的形状"。这一组讲的是类型层面的计算:条件类型、infer、映射类型、模板字面量类型,以及由此派生出的内置工具类型。这些能力让 TypeScript 的类型系统成为一个可以编程的语言。

先建立预期

这一组的能力日常写业务代码用得不多,但在写库、写通用工具、给第三方库补类型时会密集使用。更重要的是:理解它们能让你看懂别人写的类型定义——包括 React、Express、Prisma 的类型声明。

条件类型

T extends U ? X : Y 是类型层面的 if:

 1// 条件类型:T extends U ? X : Y
 2
 3type IsString<T> = T extends string ? true : false;
 4
 5type A = IsString<"a">;    // true
 6type B = IsString<1>;      // false
 7type C = IsString<string>; // true
 8
 9// 分布式条件类型:裸类型参数遇到联合会分发
10type ToArray<T> = T extends unknown ? T[] : never;
11
12// string | number 会分发给每个成员
13type D = ToArray<string | number>;   // string[] | number[]
14
15// 用 [] 包裹可以阻止分发
16type ToArrayNoDist<T> = [T] extends [unknown] ? T[] : never;
17type E = ToArrayNoDist<string | number>;   // (string | number)[]
18
19// 实用例子:排除 null 和 undefined
20type NonNullable2<T> = T extends null | undefined ? never : T;
21type F = NonNullable2<string | null | undefined>;   // string
22
23// never 在分布式条件类型中的特殊性
24type G = ToArray<never>;   // never(不是 never[])
25
26// 探测类型是否为 any(any 会同时匹配两个分支)
27type IsAny<T> = 0 extends 1 & T ? true : false;
28type H = IsAny<any>;       // true
29type I = IsAny<string>;    // false
30
31// 探测是否为 never
32type IsNever<T> = [T] extends [never] ? true : false;
33type J = IsNever<never>;   // true
34type K = IsNever<string>;  // false
35
36// 用 satisfies 之外的探测手段验证推断结果
37const a: A = true;
38const b: B = false;
39const d: D = [1];
40const e: E = [1, "a"];
41const f: F = "x";
42const h: H = true;
43const i: I = false;
44const j: J = true;
45const k: K = false;
46
47console.log(a, b, d, e, f, h, i, j, k);
1true false [ 1 ] [ 1, 'a' ] x true false true false

分布式条件类型

这是条件类型最容易踩坑的地方:当 T 是裸类型参数且传入联合类型时,条件类型会分发到每个成员。

1type ToArray<T> = T extends unknown ? T[] : never;
2
3type D = ToArray<string | number>;   // string[] | number[]

不是 (string | number)[],而是 string[] | number[]。

用方括号包裹可以阻止分发:

1type ToArrayNoDist<T> = [T] extends [unknown] ? T[] : never;
2type E = ToArrayNoDist<string | number>;   // (string | number)[]

Exclude、Extract、NonNullable 这些工具类型正是靠分发实现的:

1type NonNullable2<T> = T extends null | undefined ? never : T;
2type F = NonNullable2<string | null | undefined>;   // string

never 的特殊性

在分布式条件类型里,never 会直接返回 never,而不是分发:

1type G = ToArray<never>;   // never(不是 never[])

因为 never 是空联合,没有成员可以分发。

类型探测技巧

有些类型用 extends 判断不出来,需要技巧:

1// 探测 any:any 会同时匹配 1 和 0,用交叉类型利用这个特性
2type IsAny<T> = 0 extends 1 & T ? true : false;
3
4// 探测 never:必须用 [T] extends [never] 阻止分发
5type IsNever<T> = [T] extends [never] ? true : false;

IsNever 必须用方括号。写成 T extends never ? true : false 时,传入 never 会因为分发规则直接返回 never,而不是 true。

infer:在条件类型里提取类型

infer 声明一个"待推断的类型变量",让条件类型可以从结构里提取出类型:

 1// infer:在条件类型里声明待推断的类型变量
 2
 3// 提取函数返回类型
 4type MyReturnType<T> = T extends (...args: never[]) => infer R ? R : never;
 5type A = MyReturnType<() => string>;              // string
 6type B = MyReturnType<(x: number) => boolean>;    // boolean
 7
 8// 提取参数类型
 9type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
10type C = MyParameters<(a: string, b: number) => void>;   // [string, number]
11
12// 提取数组元素类型
13type ElementOf<T> = T extends (infer E)[] ? E : never;
14type D = ElementOf<string[]>;        // string
15type E2 = ElementOf<[1, 2, 3]>;      // 1 | 2 | 3
16
17// 提取 Promise 内部类型
18type Unwrap<T> = T extends Promise<infer U> ? U : T;
19type F = Unwrap<Promise<string>>;    // string
20type G = Unwrap<number>;             // number
21
22// 多个 infer 与位置对应
23type Swap<T> = T extends [infer A, infer B] ? [B, A] : never;
24type H = Swap<[string, number]>;     // [number, string]
25
26// infer 加约束(TS 4.7+):infer U extends ...
27type FirstString<T> = T extends [infer S extends string, ...unknown[]] ? S : never;
28type I = FirstString<["a", 1]>;      // "a"
29type J = FirstString<[1, 2]>;        // never
30
31// 递归提取嵌套 Promise
32type DeepUnwrap<T> = T extends Promise<infer U> ? DeepUnwrap<U> : T;
33type K = DeepUnwrap<Promise<Promise<string>>>;   // string
34
35// 提取函数 this 类型
36type ThisOf<T> = T extends (this: infer U, ...args: never[]) => unknown ? U : never;
37type L = ThisOf<(this: { id: number }) => void>;   // { id: number }
38
39// 提取构造函数实例类型
40type InstanceOf<T> = T extends new (...args: never[]) => infer R ? R : never;
41type M = InstanceOf<typeof Date>;    // Date
42
43const a: A = "s";
44const b: B = true;
45const c: C = ["s", 1];
46const d: D = "s";
47const e: E2 = 1;
48const f: F = "s";
49const g: G = 1;
50const h: H = [1, "s"];
51const i: I = "a";
52const j: J = undefined as never;
53const k: K = "s";
54const l: L = { id: 1 };
55const m: M = new Date();
56
57console.log(a, b, c, d, e, f, g, h, i, j, k, l, m instanceof Date);
1s true [ 's', 1 ] s 1 s 1 [ 1, 's' ] a undefined s { id: 1 } true

常见用途:

目标写法
提取返回类型T extends (...args: never[]) => infer R ? R : never
提取参数类型T extends (...args: infer P) => unknown ? P : never
提取数组元素T extends (infer E)[] ? E : never
拆开 PromiseT extends Promise<infer U> ? U : T
交换元组T extends [infer A, infer B] ? [B, A] : never
提取构造函数实例T extends new (...args: never[]) => infer R ? R : never

infer 带约束(TS 4.7+)

1type FirstString<T> = T extends [infer S extends string, ...unknown[]] ? S : never;
2
3type I = FirstString<["a", 1]>;   // "a"
4type J = FirstString<[1, 2]>;     // never

约束写在 infer S extends string 里,不满足时该分支不匹配。

递归 infer

infer 可以配合递归处理嵌套结构:

1type DeepUnwrap<T> = T extends Promise<infer U> ? DeepUnwrap<U> : T;
2type K = DeepUnwrap<Promise<Promise<string>>>;   // string
递归深度有限制

TypeScript 对递归类型有实例化深度上限,过深会报 error TS2589: Type instantiation is excessively deep and possibly infinite。

实测(TS 7.0.2,形如 type Deep<T> = T extends readonly [infer U] ? Deep<U> : T 的单层递归):

嵌套层数结果
950通过
1000TS2589

实际上限与递归的具体形状有关(旧版本报告的上限低得多,TS 7 明显提高),所以不要把这个数字当硬指标。要点是:写递归类型时留个基础分支,别让它无限展开。

映射类型

映射类型遍历一个类型的所有键,生成新类型:

 1// 映射类型:把旧类型的每个键变换成新类型
 2
 3interface User {
 4  id: number;
 5  name: string;
 6  email: string;
 7}
 8
 9// 基础映射:遍历所有键
10type Partial2<T> = { [K in keyof T]?: T[K] };
11type Required2<T> = { [K in keyof T]-?: T[K] };      // -? 去掉可选
12type Readonly2<T> = { readonly [K in keyof T]: T[K] };
13type Mutable<T> = { -readonly [K in keyof T]: T[K] }; // -readonly 去掉只读
14
15// 值类型变换
16type Stringify<T> = { [K in keyof T]: string };
17
18// 键重映射 as(TS 4.1+):改名或过滤
19type Getters<T> = {
20  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
21};
22
23type GetterUser = Getters<User>;
24// { getId: () => number; getName: () => string; getEmail: () => string }
25
26// 用 as 过滤键:只保留特定类型
27type PickByType<T, U> = {
28  [K in keyof T as T[K] extends U ? K : never]: T[K];
29};
30type OnlyStrings = PickByType<User, string>;   // { name: string; email: string }
31
32// 用 as 排除键
33type OmitByType<T, U> = {
34  [K in keyof T as T[K] extends U ? never : K]: T[K];
35};
36type WithoutStrings = OmitByType<User, string>;  // { id: number }
37
38// 从联合生成对象
39type Flags = "a" | "b";
40type FlagMap = { [K in Flags]: boolean };   // { a: boolean; b: boolean }
41
42// 同态映射:{ [K in keyof T]: ... } 会保留修饰符
43// 非同态映射(用条件类型变换 keyof T)会丢失修饰符
44type Homomorphic<T> = { [K in keyof T]: T[K] };   // 保留 readonly / ?
45
46// 验证结果
47const getters: GetterUser = {
48  getId: () => 1,
49  getName: () => "a",
50  getEmail: () => "b",
51};
52
53const onlyStr: OnlyStrings = { name: "a", email: "b" };
54const withoutStr: WithoutStrings = { id: 1 };
55const flags: FlagMap = { a: true, b: false };
56const p: Partial2<User> = { name: "a" };
57const r: Required2<Partial2<User>> = { id: 1, name: "a", email: "b" };
58const str: Stringify<User> = { id: "1", name: "a", email: "b" };
59
60console.log(getters.getId(), onlyStr.name, withoutStr.id, flags.a, p.name, r.id, str.id);
11 a 1 true a 1 1

基本形态与修饰符

1type Partial2<T> = { [K in keyof T]?: T[K] };
2type Required2<T> = { [K in keyof T]-?: T[K] };      // -? 去掉可选
3type Readonly2<T> = { readonly [K in keyof T]: T[K] };
4type Mutable<T> = { -readonly [K in keyof T]: T[K] }; // -readonly 去掉只读

+ 和 - 前缀用来增删修饰符。+ 是默认值,所以 +? 等于 ?。

键重映射 as(TS 4.1+)

as 子句可以改名或过滤键:

 1// 批量加前缀
 2type Getters<T> = {
 3  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
 4};
 5
 6// 按值类型过滤键
 7type PickByType<T, U> = {
 8  [K in keyof T as T[K] extends U ? K : never]: T[K];
 9};
10type OnlyStrings = PickByType<User, string>;   // { name: string; email: string }

as 后面返回 never 时,该键被移除——这就是 PickByType 过滤的实现原理。

string & K 的写法是必须的:K 可能是 string | number | symbol,而模板字面量只接受能转成字符串的类型。

同态映射

{ [K in keyof T]: ... } 这种形式叫同态映射(homomorphic mapped type),它会自动保留原类型的 readonly 和 ? 修饰符。

实测对比(U = { readonly a: number; b?: string }):

写法保留 readonly保留 ?
{ [K in keyof T]: T[K] }✓✓
{ [K in keyof T as K]: T[K] }✓✓
{ [K in keyof T & string]: T[K] }✗✗

结论:直接用 keyof T 或 keyof T as ... 都是同态的,修饰符保留。但一旦对 keyof T 做了运算(如 keyof T & string),就不再是同态,修饰符全部丢失:

1type H3<T> = { [K in keyof T & string]: T[K] };
2type R = H3<{ readonly a: number; b?: string }>;
3// R 的 a 不再只读,b 不再可选
4// error TS2741: Property 'b' is missing in type '{ a: number; }' but required in type 'H3<U>'.

as 重映射本身不破坏同态性——这点容易记反,实测才能确认。

模板字面量类型

模板字面量类型(TS 4.1+)在类型层面做字符串拼接与模式匹配:

 1// 模板字面量类型(TS 4.1+):在类型层面做字符串模式匹配
 2
 3type EventName = "click" | "focus";
 4type Handler = `on${Capitalize<EventName>}`;   // "onClick" | "onFocus"
 5
 6// 内置的字符串操作类型
 7type A = Uppercase<"abc">;    // "ABC"
 8type B = Lowercase<"ABC">;    // "abc"
 9type C = Capitalize<"abc">;   // "Abc"
10type D = Uncapitalize<"Abc">; // "abc"
11
12// 与泛型结合:从字符串提取部分
13type ExtractRoute<T> = T extends `/${infer Rest}` ? Rest : never;
14type E = ExtractRoute<"/users/1">;    // "users/1"
15
16// 提取两段
17type Split<T> = T extends `${infer Head}/${infer Tail}` ? [Head, Tail] : never;
18type F = Split<"a/b/c">;   // ["a", "b/c"](只切第一段)
19
20// 解析键值对
21type ParseKV<T> = T extends `${infer K}=${infer V}` ? { key: K; value: V } : never;
22type G = ParseKV<"name=rainboy">;   // { key: "name"; value: "rainboy" }
23
24// 把蛇形转驼峰(组合递归 + 映射)
25type SnakeToCamel<S extends string> =
26  S extends `${infer Head}_${infer Tail}`
27    ? `${Head}${Capitalize<SnakeToCamel<Tail>>}`
28    : S;
29
30type H = SnakeToCamel<"user_first_name">;   // "userFirstName"
31
32// 与映射类型组合:批量改键名
33interface ApiResponse {
34  user_id: number;
35  user_name: string;
36}
37type CamelKeys<T> = {
38  [K in keyof T as SnakeToCamel<string & K>]: T[K];
39};
40type I = CamelKeys<ApiResponse>;
41// { userId: number; userName: string }
42
43// 模板字面量的联合分发
44type Size = "sm" | "md";
45type Color = "red" | "blue";
46type Variant = `${Size}-${Color}`;   // 4 个成员的联合
47
48// 数字也能用,但 ${number} 只匹配合法的数字字面量
49type SemverMajor = `v${number}`;
50const v1: SemverMajor = "v1";
51const v1_5: SemverMajor = "v1.5";
52// @ts-expect-error TS2322: "v1.2.3" 不是单个 number 字面量
53const v1_2_3: SemverMajor = "v1.2.3";
54
55// 需要任意字符串时用 ${string}
56type Version = `v${string}`;
57const vAny: Version = "v1.2.3";
58
59const a: A = "ABC";
60const b: B = "abc";
61const c: C = "Abc";
62const d: D = "abc";
63const e: E = "users/1";
64const f: F = ["a", "b/c"];
65const g: G = { key: "name", value: "rainboy" };
66const h: H = "userFirstName";
67const i: I = { userId: 1, userName: "a" };
68const variant: Variant = "sm-red";
69
70console.log(a, b, c, d, e, f, g, h, i, variant, v1, v1_5, vAny);
1ABC abc Abc abc users/1 [ 'a', 'b/c' ] { key: 'name', value: 'rainboy' } userFirstName { userId: 1, userName: 'a' } sm-red v1 v1.5 v1.2.3

四个内置字符串操作类型

1Uppercase<"abc">      // "ABC"
2Lowercase<"ABC">      // "abc"
3Capitalize<"abc">     // "Abc"
4Uncapitalize<"Abc">   // "abc"

模式匹配

配合 infer 可以解析字符串:

1type ParseKV<T> = T extends `${infer K}=${infer V}` ? { key: K; value: V } : never;
2type G = ParseKV<"name=rainboy">;   // { key: "name"; value: "rainboy" }

递归转换

模板字面量类型可以递归,这是"蛇形转驼峰"这类工具的实现方式:

1type SnakeToCamel<S extends string> =
2  S extends `${infer Head}_${infer Tail}`
3    ? `${Head}${Capitalize<SnakeToCamel<Tail>>}`
4    : S;
5
6type H = SnakeToCamel<"user_first_name">;   // "userFirstName"

配合映射类型的 as 就能批量改对象键名:

1type CamelKeys<T> = {
2  [K in keyof T as SnakeToCamel<string & K>]: T[K];
3};

联合分发

模板字面量遇到联合类型会做笛卡尔积:

1type Size = "sm" | "md";
2type Color = "red" | "blue";
3type Variant = `${Size}-${Color}`;   // 4 个成员的联合

${number} 的边界

1type SemverMajor = `v${number}`;
2const v1: SemverMajor = "v1";        // 通过
3const v1_5: SemverMajor = "v1.5";    // 通过
4
5// @ts-expect-error TS2322: "v1.2.3" 不是单个 number 字面量
6const v1_2_3: SemverMajor = "v1.2.3";
7
8type Version = `v${string}`;         // 需要任意字符串时用 string
9const vAny: Version = "v1.2.3";

实测错误消息:

1error TS2322: Type '"v1.2.3"' is not assignable to type '`v${number}`'.

${number} 只匹配合法的数字字面量,1.2.3 不是。

keyof 与索引访问

 1// keyof 与索引访问类型
 2
 3interface User {
 4  id: number;
 5  name: string;
 6  address: {
 7    city: string;
 8    zip: string;
 9  };
10}
11
12type UserKeys = keyof User;            // "id" | "name" | "address"
13type IdType = User["id"];              // number
14type AddrType = User["address"];       // { city: string; zip: string }
15type CityType = User["address"]["city"];  // string
16
17// 联合索引访问会分发
18type Values = User["id" | "name"];     // number | string
19type AllValues = User[keyof User];     // number | string | { city: string; zip: string }
20
21// keyof 联合类型:取交集
22type A = keyof { a: 1; b: 2 };         // "a" | "b"
23type B = keyof ({ a: 1 } | { b: 2 });  // never(没有公共键)
24type C = keyof ({ a: 1; c: 3 } | { a: 1; b: 2 });  // "a"
25
26// keyof 交叉类型:取并集
27type D = keyof ({ a: 1 } & { b: 2 });  // "a" | "b"
28
29// keyof 数组
30type E = keyof string[];               // number | "length" | "push" | ... 全部数组方法
31type F = keyof readonly string[];      // 去掉可变方法
32
33// keyof any / keyof never / keyof unknown
34type G = keyof any;                    // string | number | symbol
35type H = keyof never;                  // string | number | symbol
36type I = keyof unknown;                // never
37
38// typeof + keyof 组合:从值取键
39const config = { debug: true, level: 1 };
40type ConfigKey = keyof typeof config;   // "debug" | "level"
41
42// 索引签名下的 keyof
43type Dict = { [k: string]: number };
44type J = keyof Dict;                    // string | number(数字键也算)
45
46// 数字索引与字符串键的关系
47type K = keyof { 0: "a"; name: "b" };   // 0 | "name"
48
49// 用 keyof 做类型安全的取值函数
50function pluck<T, K extends keyof T>(obj: T, keys: K[]): T[K][] {
51  return keys.map((k) => obj[k]);
52}
53
54const u: User = { id: 1, name: "a", address: { city: "bj", zip: "1" } };
55const picked = pluck(u, ["id", "name"]);   // (number | string)[]
56
57const a: UserKeys = "id";
58const b: Values = 1;
59const c: C = "a";
60const d: D = "b";
61const g: G = "x";
62const h: H = 1;
63const configKey: ConfigKey = "debug";
64const j: J = "any";
65const k: K = 0;
66
67console.log(a, b, c, d, g, h, configKey, j, k, picked, u.address.city);
1id 1 a b x 1 debug any 0 [ 1, 'a' ] bj

keyof 的边界行为(均已实测)

表达式结果
keyof { a: 1; b: 2 }"a" | "b"
keyof ({ a: 1 } | { b: 2 })never ← 取交集,无公共键
keyof ({ a: 1; c: 3 } | { a: 1; b: 2 })"a"
keyof ({ a: 1 } & { b: 2 })"a" | "b" ← 交叉取并集
keyof anystring | number | symbol
keyof neverstring | number | symbol
keyof unknownnever
keyof { [k: string]: number }string | number

联合类型取交集、交叉类型取并集,这个方向容易记反。

索引访问类型

1type IdType = User["id"];                  // number
2type CityType = User["address"]["city"];   // string
3type AllValues = User[keyof User];         // 所有值的联合

T[K] 就是"取 T 上 K 这个键的类型",可以嵌套、可以用联合作为键。

typeof + keyof 组合

从值反推键:

1const config = { debug: true, level: 1 };
2type ConfigKey = keyof typeof config;   // "debug" | "level"

注意 keyof 和 typeof 的顺序不能反。typeof 在类型位置是类型查询(见 typeof 的两种含义)。

内置工具类型

 1// 内置工具类型:逐个验证行为
 2
 3interface User {
 4  id: number;
 5  name: string;
 6  email: string;
 7  age: number;
 8}
 9
10// ---- 对象变换 ----
11type P = Partial<User>;       // 全部可选
12type R = Required<P>;         // 全部必需
13type RO = Readonly<User>;     // 全部只读
14type Picked = Pick<User, "id" | "name">;      // 只保留指定键
15type Omitted = Omit<User, "email">;           // 排除指定键
16type Rec = Record<"a" | "b", number>;         // { a: number; b: number }
17
18// ---- 联合变换 ----
19type U = Exclude<"a" | "b" | "c", "a">;       // "b" | "c"
20type I = Extract<"a" | "b" | "c", "a" | "b">; // "a" | "b"
21type NN = NonNullable<string | null | undefined>;  // string
22
23// ---- 函数变换 ----
24type Fn = (a: string, b: number) => boolean;
25type Ret = ReturnType<Fn>;        // boolean
26type Params = Parameters<Fn>;     // [string, number]
27type ThisT = ThisParameterType<Fn>;   // unknown
28type NoThis = OmitThisParameter<Fn>;  // 去掉 this 的函数类型
29type Constr = abstract new () => User;
30type Inst = InstanceType<new () => User>;   // User
31
32// ---- Promise ----
33type A1 = Awaited<Promise<string>>;                 // string
34type A2 = Awaited<Promise<Promise<number>>>;        // number
35type A3 = Awaited<string | Promise<number>>;        // string | number
36
37// ---- 字符串(TS 4.1+)----
38type S1 = Uppercase<"a">;     // "A"
39type S2 = Lowercase<"A">;     // "a"
40type S3 = Capitalize<"ab">;   // "Ab"
41type S4 = Uncapitalize<"Ab">; // "ab"
42
43// ---- 组合使用 ----
44// 把某个键变成可选
45type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
46type WithOptionalEmail = PartialBy<User, "email">;
47
48// 把某个键变成必需
49type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
50
51// 只改一个键的类型
52type Override<T, K extends keyof T, V> = Omit<T, K> & { [P in K]: V };
53type UserWithStringId = Override<User, "id", string>;
54
55// 深度 Partial(递归)
56type DeepPartial<T> = {
57  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
58};
59
60// ---- 验证 ----
61const partial: P = { id: 1 };
62const required: R = { id: 1, name: "a", email: "b", age: 1 };
63const picked: Picked = { id: 1, name: "a" };
64const omitted: Omitted = { id: 1, name: "a", age: 1 };
65const rec: Rec = { a: 1, b: 2 };
66const excl: U = "b";
67const extr: I = "a";
68const nonNull: NN = "s";
69const ret: Ret = true;
70const params: Params = ["s", 1];
71const inst: Inst = { id: 1, name: "a", email: "b", age: 1 };
72const a1: A1 = "s";
73const a2: A2 = 1;
74const a3: A3 = "s";
75const s1: S1 = "A";
76const s2: S2 = "a";
77const s3: S3 = "Ab";
78const s4: S4 = "ab";
79const optEmail: WithOptionalEmail = { id: 1, name: "a", age: 1 };
80const strId: UserWithStringId = { id: "1", name: "a", email: "b", age: 1 };
81const deep: DeepPartial<{ a: { b: { c: number } } }> = { a: { b: {} } };
82
83console.log(partial, required, picked, omitted, rec, excl, extr, nonNull, ret, params);
84console.log(inst, a1, a2, a3, s1, s2, s3, s4, optEmail, strId, deep);
1{ id: 1 } { id: 1, name: 'a', email: 'b', age: 1 } { id: 1, name: 'a' } { id: 1, name: 'a', age: 1 } { a: 1, b: 2 } b a s true [ 's', 1 ]
2{ id: 1, name: 'a', email: 'b', age: 1 } s 1 s A a Ab ab { id: 1, name: 'a', age: 1 } { id: '1', name: 'a', email: 'b', age: 1 } { a: { b: {} } }

对象变换

工具类型作用实现要点
Partial<T>全部可选[K in keyof T]?: T[K]
Required<T>全部必需[K in keyof T]-?: T[K]
Readonly<T>全部只读readonly [K in keyof T]: T[K]
Pick<T, K>保留指定键[P in K]: T[P]
Omit<T, K>排除指定键Pick<T, Exclude<keyof T, K>>
Record<K, V>构造键值对象[P in K]: V

联合变换

工具类型作用
Exclude<T, U>从 T 中排除可赋给 U 的成员
Extract<T, U>只保留可赋给 U 的成员
NonNullable<T>排除 null 和 undefined

Exclude 和 Extract 是分布式条件类型的直接应用:

1type Exclude<T, U> = T extends U ? never : T;
2type Extract<T, U> = T extends U ? T : never;

函数变换

工具类型作用
ReturnType<T>返回类型
Parameters<T>参数元组
ThisParameterType<T>this 类型
OmitThisParameter<T>去掉 this 参数
ConstructorParameters<T>构造函数参数元组
InstanceType<T>构造函数实例类型
ReturnType 对重载函数取最后一个签名

实测确认:对重载函数使用 ReturnType 时,得到的是最后一个重载签名的返回类型,而不是所有签名的联合。这是 TypeScript 的既有行为,写类型时要注意。

Promise 与字符串

工具类型作用
Awaited<T>递归拆开 Promise
Uppercase / Lowercase大小写转换
Capitalize / Uncapitalize首字母转换

Awaited 支持三种形态(已实测):

1Awaited<Promise<string>>              // string
2Awaited<Promise<Promise<number>>>     // number(递归拆开)
3Awaited<string | Promise<number>>     // string | number

组合出项目专用工具

内置工具类型不够用时,用它们拼:

 1// 把某个键变成可选
 2type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
 3
 4// 只改一个键的类型
 5type Override<T, K extends keyof T, V> = Omit<T, K> & { [P in K]: V };
 6
 7// 深度 Partial
 8type DeepPartial<T> = {
 9  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
10};

Omit & Pick 是"改一个键"的标准套路:先删掉它,再用新类型加回来。

satisfies 与 as const

 1// satisfies(TS 4.9+)与 as const 的分工
 2
 3// 问题:注解会丢失推断
 4type Route = { path: string; method: "GET" | "POST" };
 5const routes1: Route[] = [{ path: "/a", method: "GET" }];
 6// routes1[0].method 的类型是 "GET" | "POST",丢失了具体值
 7
 8// 不注解会丢失检查
 9const routes2 = [{ path: "/a", method: "GETT" }];   // 拼错了也不报错
10
11// satisfies:既检查又保留推断
12const routes3 = [
13  { path: "/a", method: "GET" },
14  { path: "/b", method: "POST" },
15] satisfies Route[];
16// 数组元素类型是每个元素类型的联合,所以索引访问得到的是联合
17type Route3Method = (typeof routes3)[number]["method"];   // "GET" | "POST"
18
19// 单个对象用 satisfies 才能保留具体字面量
20const single = { path: "/a", method: "GET" } satisfies Route;
21const r3: "GET" = single.method;      // 通过:保留字面量 "GET"
22
23// 想要数组也保留每个位置的字面量,用 as const satisfies
24const tuple = [
25  { path: "/a", method: "GET" },
26  { path: "/b", method: "POST" },
27] as const satisfies readonly Route[];
28const t0: "GET" = tuple[0].method;    // 通过:元组保留了位置信息
29
30// as const:让所有属性变成只读字面量
31const config1 = { mode: "dark", level: 1 } as const;
32// config1.mode 的类型是 "dark",不是 string
33// config1 的每个属性都是 readonly
34
35// satisfies + as const 组合:最严格的写法
36const config2 = {
37  mode: "dark",
38  level: 1,
39  tags: ["a", "b"],
40} as const satisfies { mode: string; level: number; tags: readonly string[] };
41
42const mode2: "dark" = config2.mode;
43const tag2: "a" = config2.tags[0];
44
45// satisfies 用于 Record:检查键完整且值合法
46type Env = "dev" | "prod";
47const urls = {
48  dev: "http://localhost",
49  prod: "https://example.com",
50} satisfies Record<Env, string>;
51// 少写一个键会报错
52
53// 不用 satisfies 时:注解会丢失 key 的精确类型
54const urls2: Record<Env, string> = { dev: "a", prod: "b" };
55type K2 = keyof typeof urls2;    // Env
56type K3 = keyof typeof urls;     // "dev" | "prod"(satisfies 保留具体键)
57
58// as const 的深度:递归应用于所有嵌套
59const nested = { a: { b: { c: 1 } } } as const;
60// nested.a.b.c 的类型是 1,且全部 readonly
61
62// 对比:as const 与显式 readonly
63type RO = { readonly a: 1 };
64const ro: RO = { a: 1 };
65
66const r1: "GET" | "POST" = routes1[0]!.method;
67const r3m: Route3Method = "GET";
68const t0m: "GET" = t0;
69const r2: string = routes2[0]!.method;
70const c1: "dark" = config1.mode;
71const k2: K2 = "dev";
72const k3: K3 = "dev";
73
74console.log(r1, r2, r3, r3m, t0m, mode2, tag2, c1, k2, k3, urls.dev, urls2.prod, ro.a, nested.a.b.c);
1GET GETT GET GET GET dark a dark dev dev http://localhost b 1 1

注解的两个问题

1// 问题一:注解会丢失推断
2const routes1: Route[] = [{ path: "/a", method: "GET" }];
3// routes1[0].method 是 "GET" | "POST",丢失了具体值
4
5// 问题二:不注解会丢失检查
6const routes2 = [{ path: "/a", method: "GETT" }];   // 拼错了也不报错

satisfies 同时解决两者:

1const routes3 = [
2  { path: "/a", method: "GET" },
3  { path: "/b", method: "POST" },
4] satisfies Route[];
5// 有检查,且保留了具体的字面量类型

satisfies 的一个细节

数组用 satisfies 后,索引访问得到的是元素类型的联合:

 1type Route3Method = (typeof routes3)[number]["method"];   // "GET" | "POST"
 2
 3// 单个对象才保留具体字面量
 4const single = { path: "/a", method: "GET" } satisfies Route;
 5const r3: "GET" = single.method;      // 通过
 6
 7// 数组要保留每个位置的字面量,用 as const satisfies
 8const tuple = [
 9  { path: "/a", method: "GET" },
10  { path: "/b", method: "POST" },
11] as const satisfies readonly Route[];
12const t0: "GET" = tuple[0].method;    // 通过

常用组合

 1// 检查对象键完整
 2const urls = {
 3  dev: "http://localhost",
 4  prod: "https://example.com",
 5} satisfies Record<Env, string>;      // 少写一个键会报错
 6
 7// 最严格:只读 + 字面量 + 检查
 8const config = {
 9  mode: "dark",
10  tags: ["a", "b"],
11} as const satisfies { mode: string; tags: readonly string[] };

声明合并

同名声明会按规则合并,这是给第三方库补类型的基础:

 1// 声明合并:同名声明的合并规则
 2
 3// 1. interface 合并:成员累加
 4interface Box {
 5  width: number;
 6}
 7interface Box {
 8  height: number;
 9}
10// Box 现在同时有 width 和 height
11const box: Box = { width: 1, height: 2 };
12
13// 同名同类型的成员可以重复
14interface Dup {
15  a: string;
16}
17interface Dup {
18  a: string;      // 类型相同,允许
19}
20// interface Dup { a: number }   // 类型不同会报错
21
22// 2. 方法重载顺序:后声明的优先
23interface Calc {
24  add(x: string): string;
25}
26interface Calc {
27  add(x: number): number;
28}
29// 用真实实现代替 declare,避免运行时未定义
30const calc = {
31  add(x: string | number) { return x; },
32} as Calc;
33const added = calc.add(1);      // number(后声明的签名优先)
34
35// 3. namespace 合并
36namespace Util {
37  export const a = 1;
38}
39namespace Util {
40  export const b = 2;
41}
42// Util 同时有 a 和 b
43
44// 4. namespace 与 class/function/enum 合并
45class Widget {
46  render() { return "w"; }
47}
48namespace Widget {
49  export const version = "1.0";
50}
51// Widget 既是类,也带静态成员 version
52const w = new Widget();
53const ver = Widget.version;
54
55// 5. function 与 namespace 合并
56function greet() { return "hi"; }
57namespace greet {
58  export const times = 1;
59}
60greet();
61const t = greet.times;
62
63// 6. enum 与 namespace 合并:给枚举加方法
64enum Color {
65  Red,
66  Green,
67}
68namespace Color {
69  export function name(c: Color): string {
70    return c === Color.Red ? "red" : "green";
71  }
72}
73const colorName = Color.name(Color.Red);
74
75// 7/8/9. declare global、declare module、模块扩充
76// 分别写在同目录的 global-extend.ts 与 ambient.d.ts 里,这里演示消费:
77import { base } from "./helper";
78const fromHelper: string = base();
79
80// 声明合并后,全局多了 __app(见 global-extend.ts)
81// 这里只做类型层面的验证,不实际赋值
82type Version = typeof __app.__APP_VERSION__;   // string
83
84// 10. 类型别名不能合并
85type T1 = { a: 1 };
86// type T1 = { b: 2 };   // 报错:Duplicate identifier 'T1'
87
88// 用 interface 合并替代
89interface Merged {
90  a: 1;
91}
92interface Merged {
93  b: 2;
94}
95
96const merged: Merged = { a: 1, b: 2 };
97
98const versionType: Version = "1.0.0";
99console.log(box, added, Util.a, Util.b, w.render(), ver, greet(), t, colorName, merged, fromHelper, versionType);
1{ width: 1, height: 2 } 1 1 2 w 1.0 hi 1 red { a: 1, b: 2 } base 1.0.0

能合并的

声明合并规则
interface成员累加(同名同类型允许,类型不同报错)
namespace成员累加
class + namespace类获得静态成员
function + namespace函数获得属性
enum + namespace枚举获得方法
declare global扩展全局作用域
declare module给不存在的模块补类型

不能合并的

1type T1 = { a: 1 };
2type T1 = { b: 2 };   // error: Duplicate identifier 'T1'

类型别名不能合并。需要合并时改用 interface:

1interface Merged { a: 1 }
2interface Merged { b: 2 }

这是 interface 与 type 里"优先用 interface 描述对象"的一条实际理由。

接口重载的顺序

多个同名 interface 声明方法时,后声明的签名优先:

1interface Calc { add(x: string): string }
2interface Calc { add(x: number): number }
3
4declare const calc: Calc;
5calc.add(1);      // number(后声明的优先)

环境声明的位置规则

declare module "xxx" 和 declare global 对文件类型有要求:

写错位置会得到 error TS2664: Invalid module name in augmentation, module 'xxx' cannot be found。

给第三方库加类型

标准做法是建一个 types/ 目录,放 declare module 声明文件,并在 tsconfig.json 的 include 里包含它。详见 .d.ts 与第三方类型。

类型体操训练路径

这一组的能力需要练习才能内化。推荐顺序:

  1. 读懂内置工具类型的实现。打开 node_modules/typescript/lib/lib.es5.d.ts,找 Partial、Pick、Exclude、ReturnType,逐个看实现。它们都很短。
  2. 自己实现一遍内置工具类型,不看源码。这是 Type Challenges 的 Easy 部分。
  3. 做 Medium 难度的题。主要涉及 infer 的位置、分布式条件类型、递归。
  4. 读一个真实库的类型定义。推荐 zod 的 z.infer、Prisma 的 Prisma.UserGetPayload,看它们怎么组合这些能力。
类型体操的收益递减

类型体操能让类型精确到极致,但代价是可读性。业务代码里 type X = A extends B ? ... : ... 嵌套三层后没人看得懂,报错信息也会变得极难读。判断标准:这段类型能不能让调用方少犯错。如果只是为了炫技,直接写宽一点更好。

小结

能力关键点
条件类型裸类型参数遇联合会分发,[T] extends [U] 阻止分发
infer从结构提取类型,可带约束 infer S extends string,可递归
映射类型as 子句改名或过滤键;同态映射保留修饰符
模板字面量字符串模式匹配,联合会做笛卡尔积,${number} 有边界
keyof联合取交集,交叉取并集
工具类型Omit & Pick 是改一个键的标准套路
satisfies既检查又保留推断;数组要配 as const 才保留位置信息
声明合并interface/namespace 可合并,type 不行

下一篇:类与装饰器。