很多人用 TypeScript,停留在"给变量标个类型"的层面 —— 这只用到了它 20% 的能力。TS 真正强大的地方,是它有一套图灵完备的类型系统:你可以在"类型"这个层面做计算、做推导、做变换。这篇把泛型、泛型约束、条件类型、infer、映射类型这几个核心武器讲透,最后你会发现 —— TS 那些内置工具类型(Partial / Pick / Omit…)全是用它们拼出来的,没有任何魔法。
泛型:类型也能当"参数"传
泛型是高级类型的地基。它解决的问题是:怎么写一个"对多种类型都适用、又不丢失类型信息"的函数或类型。
泛型:类型层面的"参数"
// 不用泛型:要么 any(丢了类型),要么为每种类型写一遍
function firstAny(arr: any[]): any { return arr[0]; }
// 用泛型:T 是一个"类型参数",调用时自动推断
function first<T>(arr: T[]): T { return arr[0]; }
first([1, 2, 3]); // T 推断为 number,返回值类型 number
first(['a', 'b']); // T 推断为 string,返回值类型 string
泛型的本质:让"类型"也能像"值"一样被传递和复用。
关键体会:<T> 里的 T 是一个"类型参数",它跟函数的普通参数是一回事 —— 只不过普通参数传的是"值",类型参数传的是"类型"。调用时你甚至不用手写 T 是什么,TS 会根据你传入的实参自动推断。泛型的本质,就是让类型也能被传递、被复用。
泛型约束:用 extends 划定范围
纯泛型 <T> 有个问题:T 可以是任何类型,所以在函数体里你不能假设 T 有任何属性 —— 你想访问 x.length,TS 会报错,因为万一传进来的 T 是 number 呢?
解决办法是泛型约束:用 extends 给类型参数划一个范围。
泛型约束:用 extends 给类型参数"划范围"
// T 可以是任何类型 —— 但这样我们就不能假设它有任何属性
function logLength<T>(x: T) { console.log(x.length); } // ✗ 报错
// 用 extends 约束:T 必须有 length 属性
function logLength<T extends { length: number }>(x: T) {
console.log(x.length); // ✓ OK
return x; // 返回值还是精确的 T,不是宽泛的对象
}
logLength('hello'); // ✓ string 有 length
logLength([1, 2, 3]); // ✓ 数组有 length
logLength(123); // ✗ number 没有 length,编译期就报错
T extends { length: number } 的意思是:"T 可以是任何类型,但前提是它得有一个 number 类型的 length 属性"。这样函数体里访问 x.length 就合法了,同时返回值还能保持是精确的 T,而不是宽泛的对象。这里的 extends 表示"约束 / 符合",不是面向对象里的"继承" —— 这是初学最容易混的一点。
条件类型:类型层面的 if-else
有了 extends 表示"符合某个形状",就能做"条件判断"了。条件类型就是类型层面的三元表达式:
条件类型:类型层面的"三元表达式" type IsString<T> = T extends string ? true : false; type A = IsString<'hello'>; // true type B = IsString<123>; // false 配合 infer 还能"提取"类型 —— infer 像一个占位符, 让 TS 帮你把某个位置的类型推断出来: type ElementType<T> = T extends (infer U)[] ? U : T; type C = ElementType<number[]>; // number —— 提取出了数组元素类型 type D = ElementType<string>; // string —— 不是数组,原样返回
T extends U ? X : Y 读作:"如果 T 符合 U,结果就是 X,否则是 Y"。
条件类型真正的威力,在于它能搭配 infer 关键字。infer U 相当于在类型里放一个占位符,告诉 TS:"这个位置的类型我不知道,你帮我推断出来,推断的结果存到 U 里。"上面 ElementType 的例子,就是用 infer 从 number[] 里把元素类型 number "抠"了出来。很多复杂的类型工具(比如提取函数返回值类型的内置 ReturnType),核心都是 infer。
映射类型:批量改造一个类型
前面都是处理"单个类型",映射类型则是批量处理一个对象类型的所有属性。它的语法核心是 [K in keyof T] —— 遍历 T 的每一个属性键。
映射类型:批量"改造"一个类型的所有属性
type User = { id: number; name: string; age: number };
// 把所有属性变成可选 —— 这就是内置的 Partial<T> 的实现原理
type MyPartial<T> = { [K in keyof T]?: T[K] };
type PartialUser = MyPartial<User>;
// { id?: number; name?: string; age?: number }
// 把所有属性变成只读 —— 内置 Readonly<T> 的原理
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
keyof T 拿到 T 所有属性名组成的联合类型,[K in ...] 像 for...in 一样遍历它们。在遍历的过程中,你可以给每个属性加 ?(变可选)、加 readonly(变只读),或者改它的值类型。TS 内置的 Partial<T> 和 Readonly<T>,实现就是上面这两行。
组合起来:亲手实现一个内置工具类型
把泛型、泛型约束、映射类型组合在一起,你就能自己实现 TS 的内置工具类型了。以 Pick 为例:
组合起来:实现一个 Pick(从类型里挑几个属性出来)
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
type UserNameAndAge = MyPick<User, 'name' | 'age'>;
// { name: string; age: number }
这就是 TS 内置工具类型(Partial / Required / Pick / Omit / Record …)
的真实实现方式 —— 它们都是用泛型 + 映射类型 + 条件类型拼出来的,
没有任何魔法。看懂它们,你就能写自己的工具类型。
读一遍这个实现:T 是源类型,K extends keyof T 约束了 K 只能是 T 里真实存在的属性名(写错属性名会直接编译报错),然后 [P in K]: T[P] 遍历 K、把这些属性原样取出来组成新类型。
这一刻你应该有种"原来如此"的感觉:Partial / Required / Pick / Omit / Record / ReturnType 这些天天在用的工具类型,没有一个是"内置魔法",它们全是用这几个基础能力拼出来的。看懂了它们,你就能按同样的套路,写出贴合自己项目的类型工具。
实战建议:别为了炫技而炫技
高级类型很强,但也最容易被滥用。几条实战经验:
- 优先用内置工具类型。能用
Partial/Pick/Omit解决的,就别自己手写条件类型。 - 高级类型的价值在"基础设施层"。写通用库、封装 API 层、做表单/状态管理这种"被很多地方复用"的代码,值得用复杂类型把约束做扎实;写普通业务组件,类型简单清晰就好。
- 类型不是越复杂越好。一个嵌套五层条件类型、谁都看不懂的类型,维护成本极高。如果一个类型你三个月后自己都读不懂,它就是负债。
- 善用 IDE 的类型悬浮提示。写复杂类型时,把鼠标悬在类型上看 TS 推断出来的结果,是调试类型最快的方式。
写在最后
TypeScript 的高级类型,本质是一套"在类型层面做编程"的工具:
- 泛型 —— 让类型能像值一样被传递、复用;
- 泛型约束(extends) —— 给类型参数划范围,表示"符合某个形状";
- 条件类型(extends ? :)+ infer —— 类型层面的 if-else 和"提取";
- 映射类型([K in keyof T]) —— 批量改造一个类型的所有属性。
这四样吃透,你看 TS 内置工具类型就像看源码一样清楚,也能写出真正为项目"兜底"的类型约束 —— 让 bug 在编译期就被拦下,而不是跑到线上才暴露。
—— 别看了 · 2026