前四组讲的是"怎么描述一个类型的形状"。这一组讲的是类型层面的计算:条件类型、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 |
| 拆开 Promise | T 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 | 通过 |
| 1000 | TS2589 |
实际上限与递归的具体形状有关(旧版本报告的上限低得多,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 any | string | number | symbol |
keyof never | string | number | symbol |
keyof unknown | never |
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 对文件类型有要求:
declare module "不存在的模块"写在全局脚本文件(无import/export)里,如ambient.d.tsdeclare global必须写在模块文件(有import/export)里,且通常配export {}显式声明为模块
写错位置会得到 error TS2664: Invalid module name in augmentation, module 'xxx' cannot be found。
标准做法是建一个 types/ 目录,放 declare module 声明文件,并在 tsconfig.json 的 include 里包含它。详见 .d.ts 与第三方类型。
类型体操训练路径
这一组的能力需要练习才能内化。推荐顺序:
- 读懂内置工具类型的实现。打开
node_modules/typescript/lib/lib.es5.d.ts,找Partial、Pick、Exclude、ReturnType,逐个看实现。它们都很短。 - 自己实现一遍内置工具类型,不看源码。这是 Type Challenges 的 Easy 部分。
- 做 Medium 难度的题。主要涉及
infer的位置、分布式条件类型、递归。 - 读一个真实库的类型定义。推荐
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 不行 |
下一篇:类与装饰器。