7AA7 博客

TypeScript 类型体操:泛型、条件类型与映射类型

苏黎 UID:114
2026-09-06 18:30
39 0 1

类型体操不是炫技。它真正的价值在于:把运行时的约束前移到编译期,让一批错误在写代码时就暴露。代价是可读性和编译时间,所以要懂得在哪里停手。

泛型约束与默认值

裸泛型 <T> 意味着”任何类型”,等于放弃检查。约束让泛型带上前提:

function getKey<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

K extends keyof T 把 key 限定为 T 的实际键名,返回值 T[K](索引访问类型)精确到了对应字段的类型。这比 Record<string, any> 强得多。

默认值的作用是减少调用处的冗余:

interface ApiResponse<TData = unknown, TError = Error> {
  data: TData;
  error: TError | null;
}

type Empty = ApiResponse;            // ApiResponse<unknown, Error>
type UserRes = ApiResponse<User>;    // 只覆盖第一个

有了默认值的参数必须排在后面,且带默认值的泛型参数可以只写一部分。

条件类型的分布式行为

条件类型的形式是 T extends U ? X : Y。关键在于:当 T 是裸类型参数且传入联合类型时,它会分发。

type ToArray<T> = T extends any ? T[] : never;
type R = ToArray<string | number>;  // string[] | number[]

分发相当于对联合的每个成员分别求值再取并集。想阻止分发,用方括号包起来:

type NoDist<T> = [T] extends [any] ? T[] : never;
type R2 = NoDist<string | number>;  // (string | number)[]

这个差异常常是 bug 来源。举一个真实例子:

type IsString<T> = T extends string ? true : false;
type A = IsString<'a' | 1>;   // boolean(true | false,分发后合并)
type B = IsString<'a' | 'b'>; // true

A 得到 boolean 而不是 true | false 的字面量联合——因为 true | false 被折叠成 boolean。想看清楚分发结果,可以用 Exclude 的思路反向验证。

never 在分发中有特殊行为:联合里出现 never 会被当作空联合处理,整个条件类型结果也是 never。

type C = ToArray<never>;  // never

利用这一点可以写过滤逻辑。

infer 的位置与推断

infer 只能在条件类型的 extends 右侧使用,用来”捕获”某个位置的类型:

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type Parameters<T> = T extends (...args: infer P) => any ? P : never;

同一个类型里可以有多个 infer,出现在不同位置就捕获不同东西:

type PromiseValue<T> = T extends Promise<infer U> ? U : T;
type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type ElementType<T> = T extends (infer E)[] ? E : never;

逆变位置上的 infer 会得到交叉类型,这是 TS 的一个非直觉规则:

type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
type R = UnionToIntersection<{ a: 1 } | { b: 2 }>;  // { a: 1 } & { b: 2 }

因为多个函数参数签名合并时,参数类型取交集(逆变)。这是把联合转交叉的标准手法。

infer 加 extends 约束也是允许的:infer R extends string 能在推断时就过滤,失败则不走该分支。

映射类型与 key 重映射

映射类型遍历键集合生成新类型:

type Readonly<T> = { readonly [K in keyof T]: T[K] };
type Mutable<T>  = { -readonly [K in keyof T]: T[K] };
type Optional<T> = { [K in keyof T]?: T[K] };
type Required<T> = { [K in keyof T]-?: T[K] };   // 去掉可选修饰符

as 子句支持键重映射,也就是边遍历边改键名甚至丢键:

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};
type Prefixed<T, P extends string> = {
  [K in keyof T as `${P}${string & K}`]: T[K]
};
// 把 never 键丢掉,等价于过滤
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K]
};

键写成 never 时该键被移除,这是映射类型做过滤的核心技巧。string & K 是必要的,因为 keyof T 可能是 string | number | symbol,模板字面量只接受 string。

递归类型的终止与深度限制

递归类型能表达嵌套结构,但必须有终止条件:

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

这里的终止靠 T extends object 分支:遇到原始类型不再递归。

TS 对递归深度有硬限制,超过会报 “Type instantiation is excessively deep and possibly infinite”。经验上大约到几十层就会触发。写递归类型时的防御手段:

  • 加深度计数器,用元组长度做递减:
type DeepPartialN<T, D extends number = 10, Acc extends unknown[] = []> =
  Acc['length'] extends D
    ? T
    : T extends object
      ? { [K in keyof T]?: DeepPartialN<T[K], D, [...Acc, unknown]> }
      : T;
  • 对已知的高层嵌套结构,直接手写 3 层而不是无限递归;
  • 用接口而非类型别名声明递归结构,接口的惰性求值有时能绕开深度检查。
// 接口形式更友好
interface Json { [k: string]: Json | string | number | boolean | null }

类型层面的性能

复杂类型是有成本的。TS 每次检查都要实例化这些类型,深层递归、大量联合展开、交叉类型归一化都会显著拖慢编译。

几条实用经验:

  • 一个类型被大量复用时,用中间类型别名缓存中间结果,避免重复计算;
  • 联合成员过多(几百个)时,分发会爆炸式增长,考虑改用查表(interface + 索引);
  • 交叉类型不像联合那样会被归一化,深层交叉会让错误提示变得极难读;
  • 用 tsc --diagnostics 或 --extendedDiagnostics 看 instantiate 计数,能定位到具体的慢类型;
  • 类型体操的收益要跟团队可读性一起算。一个 30 行的类型,如果没有注释能讲清楚,维护成本往往高于它拦截的那几个错误。

给类型写测试

复杂类型必须有验证手段,否则改一行可能悄悄破坏所有调用方。常用的是两个工具类型:

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

type _1 = Expect<Equal<PickByValue<Cfg, string>, { url: string; name: string }>>;

Equal 利用条件类型对函数签名做严格比较,能区分 any 与具体类型、{a?: 1} 与 {a: 1 | undefined}——这是 extends 做不到的。Expect 把它变成编译期断言,类型不对就直接报错。

判别联合的穷尽性检查也依赖 never:

function assertNever(x: never): never { throw new Error(`unexpected: ${x}`); }

function area(s: Shape) {
  switch (s.kind) {
    case 'circle': return Math.PI * s.r ** 2;
    case 'rect':   return s.w * s.h;
    default:       return assertNever(s);  // 新增成员时这里会报错
  }
}

default 分支收窄后类型为 never,一旦给 Shape 加了新成员,这个分支就不再是 never,编译失败提醒你补全。这比运行时枚举漏分支可靠得多。

模板字面量类型与字符串操作

模板字面量类型把字符串拼接能力带进了类型层,配合内置的 Uppercase、Lowercase、Capitalize、Uncapitalize 可以做字符串变换:

type EventName<T extends string> = `on${Capitalize<T>}`;
type Click = EventName<'click'>;  // "onClick"

它真正的用武之地是联合展开——模板字面量遇到联合类型会做笛卡尔积:

type Dir = 'top' | 'bottom' | 'left' | 'right';
type Edge = `${Dir}-${'start' | 'end'}`;
// "top-start" | "top-end" | "bottom-start" | "bottom-end" | ... 共 8 个

递归拼接还能实现字符串拆分:

type Split<S extends string, D extends string> =
  S extends `${infer H}${D}${infer R}` ? [H, ...Split<R, D>] : [S];
type Parts = Split<'a.b.c', '.'>;  // ['a', 'b', 'c']

要注意联合展开是乘性增长:两个 10 成员的联合做笛卡尔积是 100 个,三个就是 1000 个。用在字符串集合很大的场景会让编译明显变慢。

可变元组与参数列表操作

TypeScript 4.0 引入的可变元组让”操作参数列表”成为可能:

type Prepend<T extends unknown[], H> = [H, ...T];
type Concat<A extends unknown[], B extends unknown[]> = [...A, ...B];

type Fn = (...args: Concat<[string], [number, boolean]>) => void;
// (args_0: string, args_1: number, args_2: boolean) => void

带标签的元组元素能保留参数名,可读性更好:

type Range = [start: number, end: number];

配合泛型可以做”给任意函数加一个前缀参数”这类包装:

function withLogger<A extends unknown[], R>(fn: (...args: A) => R) {
  return (ctx: Ctx, ...args: A): R => { ctx.log(fn.name); return fn(...args); };
}

要点是用 A extends unknown[] 推断整个参数元组,而不是用 any[] 抹掉类型。用 any[] 能让代码编译通过,但调用方会失去全部参数检查。

类型断言的边界

类型体操解决不了的问题,往往说明数据本身缺少类型信息,此时应该改数据来源而不是堆断言。

as 的合理场景只有三个:编译器无法推断的收窄、从 unknown 转换、以及绕过第三方库的类型缺陷。用它跳过本该处理的分支,是把运行期错误藏起来。

as const 是另一回事,它保留字面量类型并让数组变只读元组,是很多推导的起点:

const routes = ['home', 'about'] as const;
type Route = typeof routes[number];  // 'home' | 'about'

satisfies 填补了 as 的一个空缺:既校验类型,又保留推导出的字面量类型。

const config = { theme: 'dark', size: 12 } satisfies Config;
// config.theme 仍是 'dark' 而不是 string,这是 as 做不到的

几个实用例子

PickByValue:按值类型挑选键

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

interface Cfg { url: string; name: string; retries: number; onDone: () => void }
type StrKeys = PickByValue<Cfg, string>;  // { url: string; name: string }

原理是先构造一个”键 → 键或 never”的映射,再用 [keyof T] 取出所有值的联合,never 自动被吸收,最后 Pick。

函数参数元组转换

// 给所有参数套上 Promise,用于包装异步接口
type AwaitedArgs<F extends (...a: any[]) => any> =
  Parameters<F> extends infer P
    ? { [I in keyof P]: Awaited<P[I]> }
    : never;

type Wrap<F extends (...a: any[]) => any> =
  (...args: AwaitedArgs<F>) => ReturnType<F>;

declare function fetchUser(id: Promise<number>, opts: Promise<{ all: boolean }>): string;
type Unwrapped = AwaitedArgs<typeof fetchUser>;  // [number, { all: boolean }]

对元组做映射时,{ [I in keyof P]: ... } 会保留元组结构而不是退化成数组,这是元组映射的特殊规则。

路径类型:安全的嵌套取值

type Path<T> = T extends object
  ? { [K in keyof T & string]:
        T[K] extends object ? `${K}` | `${K}.${Path<T[K]>}` : `${K}`
    }[keyof T & string]
  : never;

interface User { name: string; addr: { city: string; zip: number } }
type P = Path<User>;  // "name" | "addr" | "addr.city" | "addr.zip"

这类联合在对象层级多时会快速膨胀,用于配置校验、表单字段名这类键集有限的场景比较合适,放到深层嵌套的大对象上会让编译变慢。

判断该不该写某个复杂类型的标准很简单:它能不能让调用方少写一处 as any,或者能不能让一个必现的错误在编译期就暴露。两条都不占,就别写。

811浏览量
39赞
0评论
0参与用户
21 分钟阅读时间

评论(0)

还没有评论,来抢沙发。