TypeScript 类型体操进阶:条件类型、映射类型、模板字面量与 infer 递归的实战组合

深入 TypeScript 类型体操:条件类型分发语义、键重映射、模板字面量类型、infer 递归组合,手写 DeepPartial/DeepReadonly/函数重载解析等生产级工具类型。

类型体操(Type-Level Programming)是把 TypeScript 的类型系统当作一门独立的"编程语言"来使用。入门篇只解决了"会用内置工具类型",而进阶篇要回答一个更根本的问题:当内置工具类型不够用时,如何像写普通函数一样,用条件分支、递归、字符串拼接把复杂类型"计算"出来?

本文是 https://plumephp.com/typescript-advanced-types/ 的延续,聚焦于组合:单一特性解决不了的问题,靠多个特性的叠加解决。你会看到条件类型的分发语义如何被刻意抑制、模板字面量类型如何配合 infer 做字符串模式匹配、递归如何在一层层展开中构建 DeepPartial 这类"深度工具"。


1. 条件类型与分发机制的深度组合

1.1 裸类型参数的分发语义

条件类型的分发(Distribution)是所有组合技巧的地基。当一个泛型参数 T 以"裸"形式出现在 T extends U ? X : Y 中时,TypeScript 会把联合类型拆分、逐个求值、再合并成联合类型:

type FilterNullish<T> = T extends null | undefined ? never : T;

// 分发:string | number | null | undefined 被逐个求值
type Cleaned = FilterNullish<string | number | null | undefined>;
// => string | number

// 非分发版本(包裹成元组),联合类型被当作整体判断
type FilterNullishTuple<T> = [T] extends [null | undefined] ? never : T;
type CleanedTuple = FilterNullishTuple<string | number | null | undefined>;
// => string | number | null | undefined(没有分发,整体不匹配)

理解分发的关键是记住两个"触发条件":泛型参数必须是裸的(没有被元组、数组、其他泛型包裹),且必须直接出现在条件类型的左边。

1.2 用 IsEqual 做精确比较

T extends U 走的是可赋值性(Assignability),不是严格相等。string 可以被赋给 string | number,但二者显然不是同一个类型。很多工具类型需要"精确相等"语义,就必须用函数参数的双向逆变来模拟:

type IsEqual<A, B> =
  (<T>() => T extends A ? 1 : 2) extends
  (<T>() => T extends B ? 1 : 2)
    ? true
    : false;

type Test1 = IsEqual<string, string>;            // true
type Test2 = IsEqual<string, string | number>;   // false(可赋值但不相等)
type Test3 = IsEqual<{ a: 1 }, { a: 1 }>;        // true

这个技巧利用了"两个泛型函数签名是否互相可赋值"来判断 A 和 B 是否在同一位置触发相同分布,是社区公认的 IsEqual 标准实现。

1.3 识别联合类型:IsUnion

分发特性的一个推论是:只有当 T 是联合类型时,分发才会发生。因此可以反向利用这一点来判断一个类型是否为联合:

type IsUnion<T, U = T> =
  T extends any
    ? [U] extends [T] // 对每个成员,检查整体 U 是否仍等于该成员
      ? false
      : true
    : never;

type A = IsUnion<string | number>;  // true
type B = IsUnion<string>;           // false
type C = IsUnion<never>;            // false

注意 never 的特殊性:never 在条件类型中会触发空分发,因此 IsUnion<never> 需要单独处理,上面的实现通过外层分发天然规避了误判。

1.4 利用分发实现 UnionToIntersection

分发会把联合"拆开",而函数参数位置的反向分发可以把联合"合并"成交集——这是实现 UnionToIntersection 的经典套路,底层依赖逆变位置的联合到交集的转换规则:

type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends
  ((k: infer I) => void) ? I : never;

type A = { a: 1 } | { b: 2 };
// 先变成 (k: {a:1}) => void | (k: {b:2}) => void
// 逆变位置:联合的入参需要同时满足 → 交集
type Result = UnionToIntersection<A>; // { a: 1 } & { b: 2 }

这在实现"把多个对象的属性合并成一个"的场景(例如混入 Mixin)中非常有用。


2. 映射类型与键操作

2.1 键重映射(Key Remapping)

TS 4.1 之后,映射类型支持 as 子句,可以改写键名。这一能力把映射类型从"遍历键"升级为"生成新键":

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type User = { name: string; age: number };
type UserGetters = Getters<User>;
// => { getName: () => string; getAge: () => number }

Capitalize 是模板字面量类型内置的字符串工具,as 子句后可以跟任意类型表达式——这是映射类型最强大的扩展点。

2.2 过滤键:从 Pick 到 PickByValue

用 as 重映射 + never 键的技巧,可以按值类型过滤属性。Pick<T, K> 只能按已知键选取,而 PickByValue 能按属性值的类型选取:

type PickByValue<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};

type Mixed = { id: number; name: string; createdAt: Date };
type OnlyStrings = PickByValue<Mixed, string>; // { name: string }
type OnlyDates = PickByValue<Mixed, Date>;     // { createdAt: Date }

配合第 1 节的 IsEqual,还可以升级成"精确值匹配"的 PickByValueExact,避免 T[K] extends V 的子类型误判。

2.3 映射类型联合:EnumToRecord

把字符串枚举转换成 Record 类型是映射类型与键操作的又一个组合点:

enum LogLevel {
  Debug = 'debug',
  Info = 'info',
  Warn = 'warn',
  Error = 'error',
}

type LevelConfig = {
  [K in LogLevel]: { label: string; color: string };
};

// 等价于手写:
// type LevelConfig = {
//   debug: { label: string; color: string };
//   info: { label: string; color: string };
//   ...
// }

3. 模板字面量类型与字符串体操

3.1 字符串模式匹配

模板字面量类型可以配合 infer 做字符串的模式匹配,这是字符串层面的"正则"。最常见的应用是类型安全的路径参数提取:

type ExtractPathParams<Path extends string> =
  Path extends `${infer _Start}/:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ExtractPathParams<`/${Rest}`>]: string }
    : Path extends `/:${infer Param}`
      ? { [K in Param]: string }
      : {};

type Route = ExtractPathParams<'/users/:userId/posts/:postId'>;
// => { userId: string; postId: string }

3.2 大小写与格式转换工具

TS 内置 Uppercase、Lowercase、Capitalize、Uncapitalize,它们本身是模板字面量类型的内建实现。利用字符串 infer 可以组合出更复杂的分词工具,例如把 camelCase 转成 kebab-case:

type KebabCase<S extends string> =
  S extends `${infer Head}${infer Tail}`
    ? Tail extends Uncapitalize<Tail>
      ? `${Uncapitalize<Head>}${KebabCase<Tail>}`
      : `${Uncapitalize<Head>}-${KebabCase<Tail>}`
    : S;

type A = KebabCase<'createUserProfile'>; // create-user-profile
type B = KebabCase<'getHTTPResponse'>;   // get-h-t-t-p-response(对连续大写有边界)

注意 KebabCase<'getHTTPResponse'> 的结果对连续大写的处理并不完美,这是模板字面量类型的已知边界。工程上通常配合 @typescript-eslint 的命名规范,从源头避免这类输入。

3.3 生成语义化联合类型:Brand 与字面量拼接

模板字面量类型最常见的生产用途,是从运行时对象推导出语义化字符串联合:

const routes = {
  home: '/',
  users: '/users',
  userDetail: (id: number) => `/users/${id}`,
} as const;

type Routes = typeof routes;
type PathOf<T> = {
  [K in keyof T]: T[K] extends (...args: any[]) => string
    ? ReturnType<T[K]>
    : T[K];
}[keyof T];

type AllPaths = PathOf<Routes>;
// => '/' | '/users' | string(ReturnType 的推导是宽泛的 string)

当函数返回的模板字面量类型能被精确推导(如 as const + 模板字符串函数声明)时,AllPaths 就能收敛为精确的字面量联合,从而在路由跳转时获得编译期路径校验。


4. infer 递归:构建 Deep 工具类型

4.1 DeepPartial 与 DeepReadonly

infer 递归是"深度工具类型"的核心。标准库的 Partial 只处理一层,而 DeepPartial 需要把递归条件 + 映射类型组合起来,让每一层嵌套对象都被递归处理:

type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object
    ? T[P] extends Function
      ? T[P]
      : DeepPartial<T[P]>
    : T[P];
};

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object
    ? T[P] extends Function
      ? T[P]
      : DeepReadonly<T[P]>
    : T[P];
};

interface Config {
  server: {
    host: string;
    tls: { enabled: boolean; certPath: string };
  };
  plugins: string[];
}

type PartialConfig = DeepPartial<Config>;
// 每一层属性都变为可选,且深层对象继续递归展开
const cfg: PartialConfig = { server: { tls: { enabled: true } } };

两个细节值得注意:

  • Function 要显式排除,否则函数类型也会被当成 object 递归(DeepPartial 会把方法变成可选的嵌套对象,破坏调用签名)。
  • object 的判断对 Date、RegExp 等类实例同样成立,因此它们也会被递归——这通常是期望行为,但若想保留原型方法,需要在实践中按需调整。

4.2 DeepRequired 与 DeepNonNullable

与 DeepPartial 对称,DeepRequired 要去掉每一层的可选修饰符,同时剔除 undefined:

type DeepRequired<T> = {
  [P in keyof T]-?: T[P] extends object
    ? T[P] extends Function
      ? T[P]
      : DeepRequired<T[P]>
    : NonNullable<T[P]>;
};

type DeepNonNullable<T> = {
  [P in keyof T]: T[P] extends object
    ? DeepNonNullable<NonNullable<T[P]>>
    : NonNullable<T[P]>;
};

-? 修饰符在映射类型中表示"移除可选性",与 ?(添加可选性)相对;-readonly 则是移除只读。

4.3 递归处理数组与元组

深度工具类型还需要覆盖数组。T[P] extends object 对数组也成立(数组是对象),所以 DeepPartial<{ list: Item[] }> 会得到 DeepPartial<Item>[]——这通常是对的。但如果想递归数组元素本身,需要显式分发:

type DeepPartialArray<T> =
  T extends (infer U)[]
    ? DeepPartialArray<U>[]
    : T extends object
      ? { [P in keyof T]?: DeepPartialArray<T[P]> }
      : T;

type Result = DeepPartialArray<{ items: { a: { b: number } }[] }>;
// => { items?: { a?: { b?: number } }[] }

这里先用 infer U 提取数组元素,递归后再套回 [];再对非数组对象走映射类型。分支顺序决定了数组优先被处理。


5. 函数重载解析与复杂工具组合

5.1 解析函数重载:OverloadParameters 与 OverloadReturnType

内置的 Parameters<T> 只能取函数最后一个重载的参数。当需要取全部重载签名时,需要把函数类型分发成联合再逐一提取:

type OverloadParameters<T extends (...args: any) => any> =
  T extends { (...args: infer A): any } ? A : never;

type OverloadReturnType<T extends (...args: any) => any> =
  T extends { (...args: any): infer R } ? R : never;

function find(id: string): User | undefined;
function find(id: number, extra?: boolean): User | undefined;
function find(id: string | number): User | undefined { /* ... */ }

type Params = OverloadParameters<typeof find>;
// => [id: string] | [id: number, extra?: boolean]
type Returns = OverloadReturnType<typeof find>; // User | undefined

关键点:T extends { (...args: infer A): any } 对每一个重载签名分别匹配,infer A 对每个签名提取,最后合成联合——这又是一个"分发"与"infer"的组合。

5.2 Curried 函数类型

柯里化类型是"递归 + 元组推断"的经典例题。给定一个接受元组的函数,推导它被部分应用后的签名:

type Curried<F extends (...args: any[]) => any> =
  F extends (...args: infer Args) => infer R
    ? Args extends [infer First, ...infer Rest]
      ? Rest extends []
        ? F                                  // 参数已全部接收
        : (x: First) => Curried<(...rest: Rest) => R>
      : () => R
    : never;

function add(a: number, b: number): number { return a + b; }
type CurriedAdd = Curried<typeof add>;
// => (x: number) => (x: number) => number

5.3 组合实战:DeepPick 与 DeepOmit

把第 2 节(键重映射)与第 4 节(递归)组合,可以做出 Pick 的"深度版本"——按点分路径提取嵌套字段:

type DeepPick<T, Paths extends string> =
  Paths extends `${infer K}.${infer Rest}`
    ? K extends keyof T
      ? { [P in K]: DeepPick<T[K], Rest> }
      : never
    : Paths extends keyof T
      ? { [P in Paths]: T[P] }
      : never;

type Config = { server: { tls: { enabled: boolean } }; port: number };
type Picked = DeepPick<Config, 'server.tls'>;
// => { server: { tls: { enabled: boolean } } }

同理可以实现 DeepOmit(反向剔除嵌套字段)。这类工具在大型配置对象只暴露部分字段给特定模块、或做权限字段裁剪时非常实用。


6. 类型体操性能与工程最佳实践

6.1 递归深度与尾递归优化

TS 对条件类型的递归有实例化深度限制(默认 50,TS 4.5+ 支持尾递归消除)。深度递归工具类型要避免在"尾部以外"的位置递归,否则会触发 Type instantiation is excessively deep 错误:

// 尾递归位置(安全,TS 4.5+ 会优化)
type Repeat<T, N extends number, Acc extends any[] = []> =
  Acc['length'] extends N ? Acc : Repeat<T, N, [T, ...Acc]>;

把累积结果放在最后一个参数(尾位置),TypeScript 会复用栈帧,避免实例化爆炸。

6.2 复杂度控制

类型计算是编译期成本。生产团队应有意识地做复杂度预算:

策略说明适用场景
只对"热路径"写体操核心类型精雕细琢,边缘类型用简单表达API 路由、表单 schema
用 interface 而非 type 做公开 APIinterface 可声明合并、错误信息更友好库的对外类型
限制递归层数给递归加 Depth 参数Deep* 工具、路径类型
大类型拆小每个工具类型保持单一职责便于测试与复用

6.3 给递归工具加 Depth 上限

生产级 DeepPartial 通常需要防爆:

type DeepPartialLimited<T, Depth extends number = 5, Counter extends number[] = []> =
  Counter['length'] extends Depth
    ? T
    : {
        [P in keyof T]?: T[P] extends object
          ? T[P] extends Function
            ? T[P]
            : DeepPartialLimited<T[P], Depth, [0, ...Counter]>
          : T[P];
      };

Counter 元组每递归一层就追加一个元素,当 Counter['length'] 达到 Depth 时停止递归。这为"不可信/极深"的输入提供了编译期兜底。

6.4 用类型测试锁定行为

类型体操代码也是"代码",需要回归测试。推荐用 tsd 或 vitest 的 expectTypeOf 做类型断言:

// vitest + expectTypeOf
import { describe, it, expectTypeOf } from 'vitest';

type DeepPartial<T> = /* ... */;

it('DeepPartial 对嵌套对象逐层生效', () => {
  type Input = { a: { b: { c: number } } };
  expectTypeOf<DeepPartial<Input>>().toEqualTypeOf<{
    a?: { b?: { c?: number } };
  }>();
});

7. 总结与进阶路径

类型体操的价值不在"炫技",而在把运行时才能发现的问题前移到编译期。本文覆盖的组合能力可以归纳为一张能力地图:

组合能力基础构件典型产物
分发控制裸参数 / 元组包裹 / IsEqualPickByValue、UnionToIntersection、IsUnion
键变换映射类型 + as 子句Getters、PickByValue、键前缀改写
字符串匹配模板字面量 + inferExtractPathParams、KebabCase
深度递归条件类型 + 映射类型 + inferDeepPartial、DeepReadonly、DeepPick
函数重载分发 + inferOverloadParameters、Curried

下一步可以深入:

  • 在 https://plumephp.com/typescript-runtime-validation-typesafe/ 中看到类型体操如何与运行时验证(Zod)结合,用 z.infer 让 schema 与类型同源;
  • 在 https://plumephp.com/typescript-project-architecture-tsconfig/ 中了解大型项目如何配置 paths 与项目引用,为这些工具类型建立可维护的工程底座;
  • 在 https://plumephp.com/typescript-decorators-metaprogramming/ 中探索装饰器与元编程,体验"类型之上再叠一层元数据"的系统级抽象。

类型体操的边界由编译器性能与团队可读性共同决定。能用简单类型表达的,不要写体操;体操只用在"收益显著"的公共边界——这是最值得牢记的一条最佳实践。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章

  1. CSS 架构与样式方案:从方法论到现代 CSS 新特性
  2. 可访问性与国际化:WCAG 2.2、ARIA 与 i18n 工程实践
  3. SSR/SSG 渲染模式全景:Next.js App Router、流式渲染与岛屿架构