06 类型层编程配方

可直接抄用的自定义工具类型配方、递归技巧与类型体操的代价边界

06 类型层编程配方

02 讲的是内置工具类型,本页是自己写的常用配方——可以直接抄进项目。

本页所有配方均在 TS 6.0.3 实测编译通过,关键推断结果用 Eq<A, B> 断言验证。


一眼看懂写法

配方类型用在哪
Mutable<T>去只读处理第三方 readonly 数据
DeepPartial<T>深层可选递归配置合并
DeepReadonly<T>深层只读冻结配置
StrictOmit<T, K>会报错的 Omit重构时防拼写错误 🔥
KeysOfType<T, V>按值类型取键找出所有 string 属性
RequireAtLeastOne<T>至少一个查询条件参数
RequireExactlyOne<T>恰好一个互斥选项
Prettify<T>展平交叉类型让悬停提示可读 🔥
UnionToIntersection<U>联合转交叉高级组合
Brand<T, B>品牌类型区分同构 ID
NonEmptyArray<T>非空数组避免空数组分支
Exact<T, Shape>精确匹配禁止多余属性
Equals<A, B>类型相等判断类型层条件判断

修饰符类配方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 去只读(浅层)
type Mutable<T> = { -readonly [K in keyof T]: T[K] };

// 深层可选
type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

// 深层只读(正确保留数组语义)
type DeepReadonly<T> = T extends (infer U)[]
  ? readonly DeepReadonly<U>[]
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T;

用法与效果:

1
2
3
4
5
6
7
8
9
type Config = {
  readonly server: { readonly host: string; readonly port: number };
};

type P = DeepPartial<Config>;
// { server?: { host?: string; port?: number } } ✅ 每层都可选

type R = DeepReadonly<{ list: { id: number }[] }>;
// { readonly list: readonly { readonly id: number }[] } ✅ 数组也变只读

⚠️ DeepReadonly 必须显式处理数组。如果只写 T extends object ? { readonly [K in keyof T]: ... },数组会走对象分支,得到一个「看起来像数组但不是」的怪类型:

1
2
3
4
5
6
7
8
// 🛑 漏掉数组处理的后果:元组方法类型被破坏
type BadDeepReadonly<T> = T extends object
  ? { readonly [K in keyof T]: BadDeepReadonly<T[K]> }
  : T;

type Bad = BadDeepReadonly<{ list: number[] }>;
// { readonly list: { readonly [x: number]: number; ...一堆数组方法 } }
//                             ^^^^^^^^ 索引签名而不是 readonly number[]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 会检查键是否存在的 Omit 🔥
type StrictOmit<T, K extends keyof T> = Omit<T, K>;

// 精确形状:禁止多余属性
type Exact<T, Shape> = T extends Shape
  ? Exclude<keyof T, keyof Shape> extends never
    ? T
    : never
  : never;

// 类型相等判断(最可靠的实现)
type Equals<A, B> =
  (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2)
    ? true
    : false;

StrictOmit 的价值在重构时体现:

1
2
3
4
5
interface User { id: number; name: string; email: string }

type A = Omit<User, "typo">;        // ✅ 不报错,静默什么都不排除 ⚠️
type B = StrictOmit<User, "typo">;
// ❌ TS2344: Type '"typo"' does not satisfy the constraint 'keyof User'.

💡 重构时把 Omit 全换成 StrictOmit,键改名后编译器会立刻告诉你哪里漏了。这是低成本高收益的一招。

Equals 的用途——在类型层做条件分支:

1
2
3
4
5
6
7
8
type IsAny<T> = Equals<T, any>;
type IsNever<T> = Equals<T, never>;
type IsUnion<T> = T extends infer U
  ? [U] extends [T] ? ( [T] extends [U] ? false : true ) : false
  : false;

type A = IsAny<any>;        // true
type B = IsNever<never>;    // true

⚠️ 为什么不用 T extends any ? true : false 判 any:any 与任何类型都「可赋」,那个写法永远返回 true。Equals 用的是编译器内部的类型同一性,才能正确区分。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 按值类型挑键
type KeysOfType<T, V> = {
  [K in keyof T]: T[K] extends V ? K : never
}[keyof T];

// 按值类型挑属性(保留类型)
type PickByValue<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};

// 按值类型排除属性
type OmitByValue<T, V> = {
  [K in keyof T as T[K] extends V ? never : K]: T[K];
};

// 只挑可选属性 / 只挑必需属性
type OptionalKeys<T> = {
  [K in keyof T as {} extends Pick<T, K> ? K : never]: T[K];
};
type RequiredKeys<T> = {
  [K in keyof T as {} extends Pick<T, K> ? never : K]: T[K];
};
1
2
3
4
5
interface User { id: number; name: string; email: string; age?: number }

type S = KeysOfType<User, string>;      // "name" | "email"  ✅(实测)
type O = OptionalKeys<User>;            // { age?: number }
type R = RequiredKeys<User>;            // { id: number; name: string; email: string }

筛掉函数属性(序列化场景常用):

1
2
3
type DataOnly<T> = {
  [K in keyof T as T[K] extends (...args: any[]) => any ? never : K]: T[K];
};

⚠️ {} extends Pick<T, K> 检测可选属性是社区惯用法,不是语言保证的特性。它依赖 {} 可赋给「全可选对象」这一行为,实践中很稳定,但要知道它属于技巧而非规范。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 联合转交叉 🔥
type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends (k: infer I) => void
    ? I
    : never;

// 取联合的最后一个成员
type LastOf<T> = UnionToIntersection<
  T extends any ? () => T : never
> extends () => infer R ? R : never;

// 联合转元组(TS 4.x 常用,较复杂)
type UnionToTuple<T, L = LastOf<T>> = [T] extends [never]
  ? []
  : [...UnionToTuple<Exclude<T, L>>, L];

// 非空数组
type NonEmptyArray<T> = [T, ...T[]];

// 数组去重(类型层)
type Unique<T extends readonly unknown[], Acc extends unknown[] = []> =
  T extends readonly [infer H, ...infer R]
    ? H extends Acc ? Unique<R, Acc> : Unique<R, [...Acc, H]>
    : Acc;
1
2
3
type I = UnionToIntersection<{ a: 1 } | { b: 2 }>;   // { a: 1 } & { b: 2 }  ✅(实测)
type N = NonEmptyArray<string>;                      // [string, ...string[]]
type U = Unique<[1, 2, 1, 3]>;                       // [1, 2, 3]

⚠️ UnionToTuple 这类配方依赖联合成员的顺序,而联合顺序在 TS 里没有稳定保证。所以它适合「做点什么」,不适合「依赖精确顺序」。💭


「至少一个 / 恰好一个」类配方

这类配方用于互斥或必选的参数建模,是表单、查询条件、选项对象的常见需求。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 至少一个键必填
type RequireAtLeastOne<T, K extends keyof T = keyof T> =
  Omit<T, K> & { [P in K]-?: Required<Pick<T, P>> & Partial<Pick<T, Exclude<K, P>>> }[K];

// 恰好一个键必填(互斥)
type RequireExactlyOne<T, K extends keyof T = keyof T> =
  Omit<T, K> & {
    [P in K]: Required<Pick<T, P>> & Partial<Record<Exclude<K, P>, never>>
  }[K];

// 全部必填或全部不填
type AllOrNone<T> = T | { [K in keyof T]?: never };

用法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
type SearchParams = RequireAtLeastOne<{
  byId?: string;
  byName?: string;
  byEmail?: string;
}>;

const a: SearchParams = { byId: "1" };                    // ✅
const b: SearchParams = { byId: "1", byName: "x" };       // ✅ 可以多个
const c: SearchParams = {};                               // ❌ 一个都没有

type Shape = RequireExactlyOne<{ circle: number; square: number }>;

const s1: Shape = { circle: 1 };            // ✅
const s2: Shape = { circle: 1, square: 2 }; // ❌ 两个都给
const s3: Shape = {};                       // ❌ 都没给

type Opts = AllOrNone<{ timeout: number; retries: number }>;
const o1: Opts = {};                        // ✅
const o2: Opts = { timeout: 1, retries: 2 }; // ✅
const o3: Opts = { timeout: 1 };            // ❌ 只给一个

⚠️ 这类「互斥」类型有个已知限制:赋值检查严格,但函数参数的位置推断有时会放宽。它们的核心价值是在编译期拦住明显错误,不是数学意义上的完备保证。


品牌类型与名义化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
declare const brand: unique symbol;

// 通用品牌类型工厂
type Brand<T, B extends string> = T & { readonly [brand]: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Cents = Brand<number, "Cents">;
type Dollars = Brand<number, "Dollars">;

// 铸造函数(唯一允许断言的地方)
const UserId = (s: string): UserId => s as UserId;
const OrderId = (s: string): OrderId => s as OrderId;

// 内部算术保持品牌
const addCents = (a: Cents, b: Cents): Cents => (a + b) as Cents;

// 只在边界处解包
const toNumber = (c: Cents): number => c;
1
2
3
4
declare const uid: UserId;
declare const oid: OrderId;
const a: OrderId = uid;      // ❌ TS2322: 无法互赋 ✅ 正是我们要的
const b: string = uid;       // ✅ 可以当 string 用(子类型)

用私有成员实现「运行时有区分」的名义类型(04 也提到):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class UserId {
  private readonly _brand!: void;   // 只存在于类型层
  constructor(readonly value: string) {}
}

class OrderId {
  private readonly _brand!: void;
  constructor(readonly value: string) {}
}

function findUser(id: UserId) { /* ... */ }
findUser(new OrderId("x"));
// ❌ TS2345: Types have separate declarations of a private property '_brand'.
方案编译期区分运行时区分开销适合
Brand<T, B>✅❌零内部类型约束 🔥
私有成员类✅✅new 开销跨模块边界、需运行时校验
包装对象 { value: T }✅✅对象开销需要携带元数据

💭 品牌类型最常见的批评是「到处 as 很烦」。经验做法:只在系统边界(解析输入、构造实体)铸造,内部一律用品牌类型传递。边界数量有限,as 也就集中在少数几个函数里。


展平与可读性

交叉类型在编辑器悬停提示里往往显示成一长串 A & B & C & ...,很难读。

1
2
3
4
5
6
7
// 把交叉类型展平成单个对象类型 🔥
type Prettify<T> = { [K in keyof T]: T[K] } & {};

// 变体:只展平一层,保留嵌套结构
type Flatten<T> = T extends object
  ? { [K in keyof T]: T[K] }
  : T;
1
2
3
4
5
type Raw = { a: 1 } & { b: 2 } & { c: 3 };
// 编辑器悬停显示:{ a: 1 } & { b: 2 } & { c: 3 }   😖

type Clean = Prettify<Raw>;
// 编辑器悬停显示:{ a: 1; b: 2; c: 3 }            😊

💡 给公开 API 的返回类型套一层 Prettify,能显著改善使用者的编辑器体验。代价是每次类型计算多一点点开销,但完全值得。


类型层字符串处理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 拆分路径
type Split<S extends string, D extends string = "/"> =
  S extends `${infer H}${D}${infer R}` ? [H, ...Split<R, D>] : [S];

// 提取路由参数
type RouteParams<T extends string> =
  T extends `${string}:${infer P}/${infer R}`
    ? P | RouteParams<`/${R}`>
    : T extends `${string}:${infer P}`
      ? P
      : never;

// 去掉前缀
type StripPrefix<S extends string, P extends string> =
  S extends `${P}${infer R}` ? R : S;

// 短横线转驼峰
type KebabToCamel<S extends string> =
  S extends `${infer H}-${infer T}`
    ? `${H}${Capitalize<KebabToCamel<T>>}`
    : S;
1
2
3
4
type P1 = RouteParams<"/user/:id/post/:postId">;   // "id" | "postId"  ✅
type P2 = Split<"a/b/c">;                          // ["a", "b", "c"]
type P3 = StripPrefix<"onClick", "on">;            // "Click"
type P4 = KebabToCamel<"my-long-name">;            // "myLongName"

实战:类型安全的路由

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
type Routes = "/" | "/users" | "/users/:id" | "/posts/:postId/comments";

// 把 :id 位置变成必需参数
type ExtractParams<T extends string> =
  T extends `${string}:${infer P}/${infer R}`
    ? Record<P, string> & ExtractParams<`/${R}`>
    : T extends `${string}:${infer P}`
      ? Record<P, string>
      : {};

type RouteArgs<R extends string> = ExtractParams<R> extends infer P
  ? keyof P extends never ? [] : [params: P]
  : [];

function navigate<R extends Routes>(route: R, ...args: RouteArgs<R>): void { /* ... */ }

navigate("/");                                       // ✅
navigate("/users");                                  // ✅
navigate("/users/:id", { id: "1" });                 // ✅
navigate("/users/:id");                              // ❌ 缺少参数
navigate("/users/:id", { wrong: "1" });              // ❌ 参数名不对

⚠️ 这类配方的编译开销不可忽视。上面这个 navigate 在路由很多的库里会明显拖慢类型检查。路由数量超过几十条时,建议退回到「手写参数类型」。


类型体操的代价边界 💭

这是本页最重要的一节。类型层编程不是免费的。

代价一:编译时间

每多一层条件类型/映射类型,Instantiations 就多一批。

1
2
3
4
npx tsc --noEmit --extendedDiagnostics
# Types:          1234567
# Instantiations: 9876543     ← 这个数爆炸就是类型体操过重的信号
# Check time:     45.67s

代价二:错误信息不可读

1
2
type Result = DeepReadonly<DeepPartial<Prettify<UnionToIntersection<T>>>>;
// 一旦报错,错误信息里是一坨无法阅读的类型展开

代价三:编辑器变慢

悬停要算几秒、自动补全卡顿,都是类型计算过重的表现。

什么时候该收手

信号处理
Instantiations 超过百万简化类型,或缓存中间结果
编辑器悬停明显卡顿拆成多个具名类型别名
报错信息无法理解加 Prettify,或退回手写类型
为「省一次断言」写了 20 行类型🛑 不值
团队成员看不懂🛑 维护成本大于收益
类型推导结果需要写注释才能解释⚠️ 考虑简化

核心判断标准 🔥:

类型层代码是为了让「用错」变成编译错误。如果一段类型体操的收益只是「少写一个 as」,那它就不值得。

真正值得的类型体操通常满足:使用者很多、误用代价高、逻辑稳定不常改。库的公开 API 符合;业务代码里的一个内部函数通常不符合。

中间结果缓存

避免重复计算同一类型:

1
2
3
4
5
6
7
8
// 🛑 每次用都重新算
function f1<T>(x: T): DeepReadonly<DeepPartial<T>> { /* ... */ }
function f2<T>(x: T): DeepReadonly<DeepPartial<T>> { /* ... */ }

// ✅ 起个名字,通常也会被复用
type FrozenPatch<T> = DeepReadonly<DeepPartial<T>>;
function f3<T>(x: T): FrozenPatch<T> { /* ... */ }
function f4<T>(x: T): FrozenPatch<T> { /* ... */ }

💡 具名类型别名除了可读性,也有助于编译器复用已计算的结果。给复杂类型起名字是好习惯。


常见陷阱小结

陷阱症状正确做法
Omit 拼错键静默不排除用 StrictOmit
DeepReadonly 漏数组数组变成怪对象显式 T extends (infer U)[] 分支
用 T extends any 判 any永远 true用 Equals<T, any>
依赖联合顺序结果不稳定别依赖 UnionToTuple 的顺序
交叉类型不可读悬停一长串套 Prettify
类型体操过重TS2589 / 编译慢加深度计数器,或简化
为省 as 写复杂类型维护成本高权衡后可能直接 as 更好
品牌类型到处 as断言泛滥只在边界铸造
互斥类型当万能某些位置推断放宽当作「拦住常见错误」用
最后修改 September 20, 2026: 更新 (25684a4ed)