把高级类型理解成「给 IDE 看的注释」或「类型体操比赛」,会在 SDK 和组件库边界上立刻失效。条件类型、映射类型、模板字面量类型是编译期函数:输入一个类型,输出另一个。运行时 0 成本。它们的正当用途是把非法状态从调用方的自动补全里删掉,不是把错误信息变成一页 extends infer。过度体操和过度抽象是同一种病,只是发生在类型层。
这篇文章只讲能在生产边界上赚钱的那几条:distributive 怎么把联合拆开、infer 怎么抽出形状、映射怎么改字段、模板字面量怎么拼事件名、satisfies 和品牌类型怎么守住字面量。读完你应该能独立回答三个问题:为什么 ToArray<string | number> 是 string[] | number[]、什么时候该停手改写成接口、noUncheckedIndexedAccess 打开之后 arr[0] 为什么变成可能 undefined。
1. 类型是编译期函数,不是装饰
interface User { id: string } 是命名一个形状。type X = T extends U ? A : B 是算一个形状。生产代码两者都要:领域对象用接口,变换用 type。把变换写成 10 个 interface 继承,调用方看不懂;把领域对象写成 10 层 infer,错误信息看不懂。
类型检查发生在编译期。as 是在对检查器撒谎。撒谎一次,边界上的非法值会在运行时以 undefined is not a function 出现。高级类型的目标是少撒谎:让错误的调用在编辑器里红,而不是在线上红。
any 是退出检查。unknown 是「必须先收窄」。从 JSON / 网络进来的值是 unknown,不是 any,也不是你希望的 User。用校验函数(zod 一类,或手写谓词)把它变成 User。类型层的 as User 不读网络。
2. 条件类型与 distributive
T extends U ? X : Y 在 T 是裸类型参数时对联合分配:
type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string | number>; // string[] | number[]
这是联合的 map。事件表、Promise 展开、把 A | B 变成函数重载,都靠它。不想分配,把 T 包进元组:
type ToArrayWhole<T> = [T] extends [unknown] ? T[] : never;
type B = ToArrayWhole<string | number>; // (string | number)[]
string[] | number[] 和 (string | number)[] 不是一回事。前者不能 push 一个 number 进「可能是 string[]」的那个分支。选错的症状是:你认为函数接受联合,调用方传入联合却报错——往往是分配把返回值拆碎了,或反过来该拆没拆。
never 在分配里是空联合。Exclude<T, U> = T extends U ? never : T 能把成员剔掉,是因为 never | X 就是 X。Extract 相反。过滤联合用它们,不要手写一长串三元。
T extends any 看起来无意义,它的作用就是触发分配。看到它不要删,先问是不是故意在 map 联合。
3. infer:在模式里开一个绑定
infer U 只能出现在条件类型的 extends 右侧。它说:如果匹配这个形状,把这块名字叫 U。
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
type FirstArg<T> = T extends (first: infer P, ...rest: never[]) => unknown ? P : never;
type El<T> = T extends readonly (infer U)[] ? U : never;
嵌套 Promise 用递归 Awaited。不要只剥一层还在文档里写「我们不套两层 Promise」——调用方会套。递归要有底:T extends Promise<infer U> ? Awaited<U> : T 碰到非 Promise 停。没底会 Type instantiation is excessively deep。那不是 TS 的 bug,是你的函数在类型层死循环。
infer 在多个位置同时出现时,每个都是独立绑定。T extends (a: infer A, b: infer B) => infer R 抽出参数和返回值。抽不出来就 never,调用方会在 never 上失败——这比抽成 any 好,失败是可见的。
生产里 infer 的正当对象是你自己仓库的形状:路由表、SDK 响应、组件 props。不要为 Array.prototype.reduce 的类型去复刻一遍 lib.es5.d.ts。
4. 映射类型:改字段,不要复制粘贴接口
{ [K in keyof T]: X } 遍历键。修饰符 readonly / ? 可以加或减(-?、-readonly)。
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type RequiredKeys<T> = { [K in keyof T]-?: T[K] };
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
as 重映射改键名:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type EventMap = { click: MouseEvent; focus: FocusEvent };
type Handlers = {
[K in keyof EventMap as `on${Capitalize<K>}`]: (e: EventMap[K]) => void;
};
Handlers 有 onClick、onFocus,参数类型跟着事件走。漏一个 onXxx 或参数写错,调用方立刻红。这就是组件库要的:props 从一份事件表生成,而不是手写 30 个可选回调。
K in keyof T as never 可以删键。Omit 的一种实现就是重映射到 never。自己写 Omit 前先用内置的;内置的错误信息更短。
映射默认保留可选和只读。T[K] 在 K 来自 keyof T 时可能已经是 T[K] | undefined(取决于 exactOptionalPropertyTypes)。抄片段时连同 tsconfig 一起抄,否则在 A 项目能编过的工具类型在 B 项目红。
5. 模板字面量类型:把字符串当联合
on${Capitalize<K>} 在类型层做字符串拼接。K 是联合时,结果也是联合。路由、query key、CSS 变量名、i18n key,都能变成可补全的字面量。
type Route = "/users" | "/users/:id" | "/posts/:id";
type ParamName<R extends string> = R extends `${string}:${infer P}/${infer Rest}`
? P | ParamName<`/${Rest}`>
: R extends `${string}:${infer P}`
? P
: never;
type UserParams = ParamName<"/users/:id">; // "id"
解析路由参数是模板字面量的正经活。写错 id 成 Id,params.Id 红。不要用 Record<string, string> 当 params——那是在说任何键都合法。
模板类型会爆炸。${A}${B}${C} 而 A/B/C 各 20 个成员,联合是 8000。TS 会慢或报过大。空间太大就不要生成,改成「前缀 + 品牌字符串」或运行时校验。类型要为补全和非法排除服务,不为穷举宇宙服务。
6. satisfies、as const、const 泛型
标注 const r: Record<string, string> = { home: '/' } 会把 '/' 放宽成 string,字面量丢了。as const 把值收成字面量,但对象变成 readonly,有时过窄。satisfies 检查形状、保留推断:
const routes = {
home: "/",
user: "/users/:id",
} as const satisfies Record<string, string>;
type RouteKey = keyof typeof routes; // "home" | "user"
type Home = (typeof routes)["home"]; // "/"
配置表、权限表、feature flag 用这个。既能保证值是字符串,又能在 routes.home 上拿到 "/"。
const 类型参数(<const T>)让函数在调用处推断字面量:
function defineEvents<const T extends readonly string[]>(events: T) {
return events;
}
const ev = defineEvents(["click", "focus"] as const);
没有 const,T 往往是 string[],后面的模板类型全废。SDK 的 defineConfig、defineRoutes 必须用 const 或 as const,否则高级类型在入口就丢了精度。
7. 品牌类型:把 string 不是 UserId 写成类型
string 能当 user id、也能当 post id。函数 getUser(id: string) 接受任何字符串。品牌类型用交叉一个不会在运行时存在的字段,把两种 string 分开:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;
function userId(raw: string): UserId {
if (!raw) throw new Error("empty id");
return raw as UserId;
}
declare function getUser(id: UserId): Promise<unknown>;
getUser(postId) 红。运行时仍是 string,没有额外字段。as UserId 只允许出现在校验函数里,这是品牌的唯一出口。到处 as UserId 等于没品牌。
和 enum 比:品牌不生成运行时对象。和 uuid 标称类比:没有构造函数开销。适合 id、非空字符串、经过校验的 email。不要给每一个字段都品牌,错误信息会变成 Brand<string, "Foo"> 满屏。
8. 方差、索引、以及 undefined
函数参数逆变、返回值协变。(x: Animal) => void 不能当 (x: Dog) => void 用——调用方会传入 Dog,实现若只接受 Animal 的超类型……等一下:实现接受 Animal 其实能接受 Dog。TS 对函数参数默认是双变(bivariant)在方法上,严格函数类型(strictFunctionTypes)才对独立函数参数做逆变。打开它。组件的 onChange?: (v: T) => void 在严格模式下会暴露「子类型回调」错误,这是对的。
noUncheckedIndexedAccess 让 arr[0] 和 map[key] 变成 T | undefined。索引不是证明,是希望。打开后,users[0].id 会红,除非你先收窄。这会让很多「我知道一定有」的代码变吵。吵是因为那些地方本来就可能空。用 arr.at(0) 或 if (arr.length),不要 arr[0]!。
in / out 标记泛型方差(TS 4.7+):interface Box<out T> 协变。写库时标上,调用方的错误会落在声明而不是使用点。应用代码很少需要手写。
9. 取舍:体操、简单接口、运行时校验
| 工具 | 买到什么 | 代价 |
|---|---|---|
| 接口 + 联合 | 可读、错误短 | 变换要手写 |
| 条件/映射/模板 | 一份表生成全部 API | 错误长、编译慢、新人读不懂 |
| 品牌类型 | id 互斥 | as 纪律;JSON 边界仍要校验 |
satisfies | 形状检查 + 字面量 | 老 TS 没有 |
| zod 等运行时 | 真的挡住网络脏数据 | 运行时成本、schema 和类型双份 |
边界(网络、localStorage、query string)必须有运行时校验。类型在边界上是断言,不是检查。内部,TS 已经收窄的值不要再 parse 一遍。两边都做「深拷贝 + 再校验」是把编译期保证扔了。
停手标准:错误信息超过三行、或 tsc 这个文件超过一秒、或只有作者能改这个 type。停手后改成显式接口 + 几个重载,比再加一层 infer 更负责任。
10. 排障
- 「类型实例化过深」。 递归没底,或联合爆炸。加终止条件,或把联合改成映射对象。
- 分配行为不符合预期。 裸
Tvs[T]。用一个测试类型文件type _ = Assert<Equal<Actual, Expected>>钉住。 - 字面量变成
string。 缺as const/satisfies/const泛型。从入口追推断,不要在末尾as。 T[K]是T[K] | undefined。noUncheckedIndexedAccess或可选字段。用NonNullable或收窄,不要关编译选项来消红。- 库的回调类型不匹配。
strictFunctionTypes和方法双变。把回调写成独立函数类型属性,不要写成方法语法(方法双变)。 - JSON.parse 之后一切皆
any。 开noImplicitAny,给 parse 包一层校验。 - IDE 卡。 巨型模板联合。砍生成范围。
Equal 辅助可以自己写:type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false。把工具类型的预期钉在 types/xxx.test-d.ts,和单测同等重要。类型回归不会在 Jest 里红,会在调用方的编辑器里红——那时已经晚了一个版本。
11. 在 API 边界怎么用
SDK:一份 paths.ts 描述路由和方法,映射出 client.get('/users/:id', { params: { id } }) 的 params 和响应。响应类型从 OpenAPI 生成,或从 zod schema z.infer。不要手写第三份。生成物提交进仓库,CI 校验「schema 变了生成物必须变」。
组件库:variant: 'primary' | 'ghost' 用字面量联合,不要 string。as const satisfies 一张 variant 表,再映射出 className 表。props 用 Discriminated union:{ type: 'link'; href: string } | { type: 'button'; onClick: () => void },比 href?: string; onClick?: () => void 少一个非法组合(link 没有 click、button 没有 href)。
非法状态不可表示,优先于工具类型炫技。能用联合解决的,不要 infer。能用品牌解决的,不要 string 加注释。
Next / Nest 的 DTO:前端不要 interface 一份、后端 class 一份还字段不同。共享 packages/types,用类型而不是 class(class 在前端多余)。运行时校验放 Nest 的 pipe 和前端的表单,schema 能共享就共享,不能共享就至少共享字段名和联合。Monorepo 那篇会把构建图接上。
12. 小结
高级类型是编译期函数:条件做 if,分配做 map,infer 做解构,映射做字段变换,模板做字符串联合。satisfies 保住字面量,品牌把 string 切开,索引访问承认 undefined。目的是把非法调用挡在编辑器里。体操超过可读性就停。边界上类型代替不了校验。下一篇把「运行时状态图」从类型里拿出来:Zustand 的中心仓库和 Jotai 的原子网,差的是图的形状,不是谁更现代。
7. 深入 TypeScript 类型检查器底层:协变 (Covariance)、逆变 (Contravariance) 与类型跟踪性能分析
在构建高度抽象的通用库或大型单体仓库(Monorepo)时,开发者不仅要写出正确的类型体操,更要深刻理解类型系统的内部计算成本。
7.1 变型(Variance)与类型收敛
在 TypeScript 4.7 引入的显式变型标记(in 与 out)彻底优化了编译器的类型比对开销:
// 显式声明协变 (out) 与逆变 (in)
type Producer<out T> = () => T;
type Consumer<in T> = (item: T) => void;
type Mapper<in TInput, out TOutput> = (input: TInput) => TOutput;
底层收益:以往 TypeScript 在判断两个复杂泛型接口是否兼容时,必须递归展开其内部数十层属性进行深度比对;显式添加 in / out 标记后,编译器能够直接在顶层执行 O(1) 复杂度的变型判定,使大型项目的全量类型检查速度提升 20%~40%!
7.2 尾递归展开优化与避免 Type Instantiation is Excessively Deep
在递归处理元组或模板字面量时,如果未满足尾递归消除条件,极易触发 TS 2589 编译报错:
// 错误做法: 非尾递归累加,在深度递归时引发调用栈溢出
type ReverseBad<T extends any[]> = T extends [...infer Rest, infer Last] ? [Last, ...ReverseBad<Rest>] : [];
// 正确架构: 尾调用优化范式 (Tail-Call Optimization),利用累加器推入末尾
type Reverse<T extends any[], Acc extends any[] = []> =
T extends [infer Head, ...infer Tail]
? Reverse<Tail, [Head, ...Acc]>
: Acc;
TypeScript 编译器对形如 Acc 累加器模式的泛型递归拥有专门的内存优化路径,支持展开深度从默认的 50 层大幅提升至 1000 层,极大增强了工业级元编程的稳健性。
7.3 使用 --generateTrace 进行类型性能排障
tsc --noEmit --generateTrace ./traces
npx @typescript/analyze-trace ./traces
通过分析生成的跟踪火焰图,能够精准捕获哪一个泛型类型在编译期消耗了数秒的 CPU 时间,为构建秒级响应的 IDE 开发环境提供硬核数据支撑。