很多人对 TypeScript 的认识停在"给变量加类型注解"。这其实只用到了 5% 的能力。TypeScript 真正强大的地方在泛型和类型推导 —— 它们让类型系统能像写代码一样"编程"。这篇文章带你从最基础的 <T> 一路走到条件类型、映射类型、类型推导,所有例子都来自真实工程。
泛型解决的根本问题:复用 + 类型安全
看一个没有泛型的笨方法:
function firstString(arr: string[]): string { return arr[0]; }
function firstNumber(arr: number[]): number { return arr[0]; }
// 还要为每种类型写一遍...
用 any 复用?可以,但失去了类型安全:
function firstAny(arr: any[]): any { return arr[0]; }
const x = firstAny([1, 2, 3]);
x.toUpperCase(); // 编译通过,运行时崩溃
泛型同时解决"复用"和"安全":
function first<T>(arr: T[]): T { return arr[0]; }
const a = first([1, 2, 3]); // a 是 number,自动推导
const b = first(['x', 'y']); // b 是 string
// a.toUpperCase(); // 错误:number 没有 toUpperCase
关键认知:泛型不是"放任何类型",而是"占位符,具体什么类型在调用时确定"。一旦确定,后续就严格按那个类型检查。
泛型约束:用 extends 限定能接受什么
有时候你希望泛型"任意类型,但必须有某种属性"。用 extends 加约束:
// 错:T 可能是 number 这种没有 .length 的类型
function logLength<T>(x: T) { console.log(x.length); }
// 对:约束 T 必须有 length 属性
function logLength<T extends { length: number }>(x: T): T {
console.log(x.length);
return x;
}
logLength('hello'); // OK
logLength([1, 2, 3]); // OK
// logLength(42); // 错:number 没有 length
keyof 与索引访问:类型层面的"键值对"
JavaScript 里 Object.keys(obj) 返回字符串数组,但 TypeScript 在类型层面有更精细的 keyof —— 它返回对象类型所有键的字面量联合类型。
type User = { id: number; name: string; age: number };
type K = keyof User; // 'id' | 'name' | 'age'
// 索引访问类型:用类型做"下标"
type IdType = User['id']; // number
type StrField = User['name' | 'age']; // string | number
keyof + 泛型组合出最实用的一个工具函数 —— 类型安全的属性获取:
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const u: User = { id: 1, name: 'mores', age: 30 };
const name = getProp(u, 'name'); // 类型推导为 string
const age = getProp(u, 'age'); // 类型推导为 number
// getProp(u, 'email'); // 错:'email' 不是 keyof User
这个 8 行函数包含了"泛型 + 约束 + 索引访问"三个核心概念,且类型推导精确到具体属性的类型 —— 这是 any 永远做不到的。
映射类型:从一个类型变出另一个
映射类型让你"批量"修改对象类型的所有属性。基础语法:
// 把 User 的每个属性变成可选
type PartialUser = { [K in keyof User]?: User[K] };
// 把 User 的每个属性变成 readonly
type ReadonlyUser = { readonly [K in keyof User]: User[K] };
// 把 User 的每个属性的类型变成 string
type StringifiedUser = { [K in keyof User]: string };
TS 内置了几个常用映射类型,几乎天天用:
Partial<T> // 所有属性变可选
Required<T> // 所有属性变必选
Readonly<T> // 所有属性变 readonly
Pick<T, K> // 只挑选 K 中的属性
Omit<T, K> // 排除 K 中的属性
Record<K, V> // 构造一个键为 K、值为 V 的对象类型
type UserPreview = Pick<User, 'id' | 'name'>; // { id, name }
type UserWithoutId = Omit<User, 'id'>; // { name, age }
type RoleMap = Record<'admin' | 'user', string>; // { admin, user: string }
条件类型:类型层面的 if
条件类型让类型可以"根据另一个类型的形态"分支选择,语法是 T extends U ? X : Y。
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>; // false
// 实用例子:NonNullable 排除 null 和 undefined
type NotNull<T> = T extends null | undefined ? never : T;
type X = NotNull<string | null>; // string
分发条件类型(Distributive Conditional Types)
有个隐藏规则:当条件类型的检查类型是"裸"的类型参数时,联合类型会被自动"分发"。
type ToArray<T> = T extends any ? T[] : never;
type R = ToArray<string | number>;
// 不是 (string | number)[],而是 string[] | number[]
// 因为 T extends any 分发了:ToArray<string> | ToArray<number>
想阻止分发,用方括号包起来:
type ToArrayNoDist<T> = [T] extends [any] ? T[] : never;
type R2 = ToArrayNoDist<string | number>; // (string | number)[]
infer:在条件类型里"取出"一个类型
infer 让你在条件类型的 extends 子句里声明一个"待推导"的类型变量,然后在分支里使用它。这是 TypeScript 类型编程最强大的工具。
// 提取函数的返回值类型
type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never;
type F = (x: number) => string;
type R = ReturnTypeOf<F>; // string
// 提取函数的第一个参数类型
type FirstArg<T> = T extends (arg: infer A, ...rest: any) => any ? A : never;
// 提取数组的元素类型
type ElementOf<T> = T extends (infer E)[] ? E : never;
type E = ElementOf<number[]>; // number
// 提取 Promise 的 resolve 类型
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type U = Unwrap<Promise<string>>; // string
TS 标准库的 ReturnType、Parameters、Awaited 都是这种套路写出来的。
实战 1:类型安全的事件系统
泛型 + 索引访问可以让事件总线在编译期就检查"事件名"和"参数类型"是否匹配。
type EventMap = {
login: { userId: number; at: Date };
logout: { userId: number };
error: { code: string; message: string };
};
class EventBus<M extends Record<string, any>> {
private handlers: { [K in keyof M]?: Array<(payload: M[K]) => void> } = {};
on<K extends keyof M>(event: K, handler: (payload: M[K]) => void) {
(this.handlers[event] ??= []).push(handler);
}
emit<K extends keyof M>(event: K, payload: M[K]) {
this.handlers[event]?.forEach((h) => h(payload));
}
}
const bus = new EventBus<EventMap>();
bus.on('login', (p) => console.log(p.userId, p.at)); // p 推导为 { userId, at }
bus.emit('login', { userId: 1, at: new Date() }); // OK
// bus.emit('login', { userId: 1 }); // 错:缺 at
// bus.emit('unknown', {}); // 错:事件名不存在
注意 on 里写 handler 时,参数 p 不用手动注解,IDE 自动推导为对应事件的 payload 类型。这种类型驱动的开发体验,就是 TypeScript 的卖点。
实战 2:深度只读 / 深度可选
内置 Readonly / Partial 只是浅一层。要做深的,自己写递归类型:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
interface Config {
server: { host: string; port: number };
log: { level: 'info' | 'error' };
}
type ConfigPatch = DeepPartial<Config>;
// 等价于 { server?: { host?, port? }, log?: { level? } }
const patch: ConfigPatch = { server: { port: 8080 } }; // 合法
实战 3:模板字面量类型
TS 4.1 引入的模板字面量类型,能在类型层面拼接字符串字面量。它让"路由路径、CSS 类名、SQL 字段"这类字符串规约在编译期就被校验。
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = `/api/${string}`;
type Route = `${HttpMethod} ${Endpoint}`;
// 等于 'GET /api/...' | 'POST /api/...' | ...
const r1: Route = 'GET /api/users'; // OK
// const r2: Route = 'PATCH /api/users'; // 错
// 从对象属性名生成 getter 名
type Getter<T> = {
[K in keyof T & string as `get${Capitalize<K>}`]: () => T[K];
};
type UserGetter = Getter<User>;
// { getId(): number; getName(): string; getAge(): number }
类型缩窄:from any to safe
TypeScript 的类型系统支持"控制流分析" —— 在 if / switch / typeof / instanceof / in 等检查后,编译器会自动缩窄类型。
function area(s: { kind: 'circle'; r: number } | { kind: 'square'; side: number }) {
if (s.kind === 'circle') {
return Math.PI * s.r ** 2; // s 被缩窄为 circle 分支
}
return s.side ** 2; // 这里 s 必然是 square
}
// 自定义类型谓词
function isString(x: unknown): x is string {
return typeof x === 'string';
}
function format(x: unknown) {
if (isString(x)) {
return x.toUpperCase(); // 缩窄为 string
}
return String(x);
}
常见坑
坑 1:any 是病毒,unknown 是良药。 any 会让类型检查整条链路失效。需要"未知类型"时用 unknown,它强迫你在使用前做类型缩窄。
坑 2:对象字面量的"多余属性检查"。 直接传字面量时 TS 会额外检查多余属性,赋给变量后再传就不会。
type Opt = { a: number };
// fn({ a: 1, b: 2 }); // 错:b 是多余属性
const o = { a: 1, b: 2 };
fn(o); // 不报错(只检查鸭子类型)
坑 3:函数参数的双向协变。 在 strictFunctionTypes 开启前,函数参数是双向协变的,容易出"意外能赋值"的情况。务必在 tsconfig 里开 strict: true。
类型守卫与判别联合:把运行时分支和类型挂钩
在编写业务代码时最有价值的不是炫技的高级类型,而是判别联合(discriminated union)—— 用一个公共字段把多种"形态"区分开,TS 能根据这个字段自动缩窄。
type Response =
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; message: string };
function render(r: Response) {
switch (r.status) {
case 'loading': return '加载中';
case 'success': return `共 ${r.data.length} 个用户`; // 这里 r.data 类型确定
case 'error': return `出错:${r.message}`;
}
}
把它和 never 结合起来,还能做"穷尽性检查"—— 如果你以后加了新的 status 但忘记加 case,编译器会报错:
function assertNever(x: never): never { throw new Error('unreachable'); }
function render2(r: Response) {
switch (r.status) {
case 'loading': return '加载中';
case 'success': return `共 ${r.data.length}`;
case 'error': return `出错:${r.message}`;
default: return assertNever(r); // 漏写 case,这里就编译失败
}
}
const 断言:把字面量类型保住
TS 默认会"扩展"字面量类型 —— const x = 'a' 推导成 'a',但 const obj = { x: 'a' } 推导成 { x: string }。这在写配置和枚举时很烦人。as const 解决这个问题。
const routes = ['home', 'about', 'contact'] as const;
// 类型是 readonly ['home', 'about', 'contact'],不是 string[]
type Route = typeof routes[number];
// 'home' | 'about' | 'contact'
const config = {
method: 'GET',
cache: 'no-store',
} as const;
// 所有字段都是字面量类型,适合作为联合类型的来源
实战:把字符串路由变成类型安全
用模板字面量类型 + infer 解析路由路径里的参数:
type ExtractParams<Path extends string> =
Path extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractParams<Rest>]: string }
: Path extends `${string}:${infer Param}`
? { [K in Param]: string }
: {};
type P1 = ExtractParams<'/users/:id'>; // { id: string }
type P2 = ExtractParams<'/users/:uid/posts/:pid'>; // { uid: string; pid: string }
function navigate<P extends string>(path: P, params: ExtractParams<P>) {
// 强制传的参数必须和 path 里的占位符匹配
}
navigate('/users/:id', { id: '42' }); // OK
// navigate('/users/:id', {}); // 错
// navigate('/users/:id/posts/:pid', { id: 'x' }); // 错:缺 pid
这类技巧让"接口契约"从文档/约定升级为编译期检查。后端再改路由格式,所有调用方第一时间在 IDE 里飘红 —— 这就是类型驱动开发的真正价值。
写在最后
把 TypeScript 类型系统看成一门"在编译期跑的、纯函数式的小语言":泛型是函数,keyof / 索引访问是基本运算,条件类型是 if,infer 是 let,映射类型是 map,模板字面量类型是字符串拼接。掌握了这套类比,看到再复杂的类型表达式都能拆解。
工程上的判断标准:能让一个错误从"运行时崩溃"提前到"红线下波浪号",就是值得的;为了炫技把类型写得难以读懂,就是有害的。在团队代码里,优先用内置 Partial / Pick / Omit 等组合,避免堆叠超过三层的条件类型 —— 类型本身也是你要维护的代码。
—— 别看了 · 2026