第一次接触 TypeScript 的高级类型时,我感觉自己像是在学一门新编程语言。T extends U ? X : Y 这种条件类型语法,初看跟三元运算符有点像,但语义完全不是一回事。
后来我意识到:类型体操不是目的,是手段。写复杂类型最终目标,是让 TypeScript 能在编译时发现更多错误,同时让代码自动补全更智能。如果你写了一套复杂类型但最终只是为了让 tsc 不报错,那可能是走偏了。
下面从实际场景出发,讲讲那些真正有用类型技巧。
为什么需要类型体操
先说一个场景。
你有一个 API 响应类型:
// types/api.ts
interface User {
id: number;
name: string;
email: string;
password: string; // 敏感字段
createdAt: Date;
}
在列表页,你只需要 id、name、email 三个字段。但直接用 User 类型,前端代码就能访问 password—这在安全上是个问题,在代码维护上也会导致”我到底用了哪些字段”不清晰。
你可能会想:那就再定义一个 UserPreview 类型呗。
// 这样做的问题:字段改了要同步改两个地方
interface UserPreview {
id: number;
name: string;
email: string;
}
如果 User 里加了新字段,你很可能会忘记更新 UserPreview。而且两个接口之间缺乏语义关联,读代码人不知道它们是”同一个东西不同子集”。
这时候 Pick 就派上用场了:
// types/api.ts
type UserPreview = Pick<User, 'id' | 'name' | 'email'>;
UserPreview 永远是 User 的子集,当 User 改变时,TypeScript 会帮你检查 Pick 里的字段名是否还存在于 User 上。
这就是类型体操价值:用类型之间组合和推导,替代手动维护重复定义。
内置工具类型:日常开发主力
TypeScript 提供了一批内置工具类型,覆盖了很多常见需求。下面挑最实用讲。
Partial<T> 和 Required<T>
Partial<T> 把 T 的所有字段变成可选。
// 场景:API 的 PATCH 请求,只更新部分字段
interface UserUpdatePayload extends Partial<User> {
// 不能更新 password,所以排除它
}
// 上面这样写不对,因为 Partial<User> 包含了 password
// 正确做法:
type UserUpdatePayload = Partial<Omit<User, 'password'>>;
Required<T> 是反操作:把可选字段变成必需。
// 场景:某个函数要求所有配置项都必须提供
interface Config {
timeout?: number;
retries?: number;
baseUrl?: string;
}
// 在应用初始化时,我们要求所有配置项都必须有值
function initApp(config: Required<Config>) {
// 这里可以安全地访问 config.timeout,不用担心 undefined
}
Pick<T, K> 和 Omit<T, K>
Pick 选字段,Omit 排除字段。上面已经见过了。
实际使用中,这两个类型经常组合使用:
// 场景:一个表单,某些字段是只读的(比如 id),某些是需要编辑的
type UserFormData = Pick<User, 'name' | 'email'>;
// 或者等效地:
type UserFormData = Omit<User, 'id' | 'password' | 'createdAt'>;
选择哪个取决于”要选的字段少”还是”要排除字段少”。如果目标类型包含字段占总字段大部分,Omit 更简洁;反之用 Pick。
Readonly<T>
把所有字段变成只读。
// 场景:API 响应不应该被修改
type ReadonlyUser = Readonly<User>;
const user: ReadonlyUser = {
id: 1,
name: 'Alice',
// ...
};
user.name = 'Bob'; // 编译错误:Cannot assign to 'name' because it is a read-only property
这个在团队协作中很有用:当你把一个对象传给某个函数时,用 Readonly 表明”这个函数不会修改这个对象”。
// 明确表示这个函数不会修改 user
function renderUserProfile(user: Readonly<User>) {
// ...
}
Record<K, V>
创建一个键为 K 类型、值为 V 类型的对象类型。
// 场景:用枚举值做 key 的对象
type Role = 'admin' | 'user' | 'guest';
const permissions: Record<Role, string[]> = {
admin: ['read', 'write', 'delete'],
user: ['read', 'write'],
guest: ['read'],
};
// 如果漏了某个 Role,TypeScript 会报错
// 如果多了不存在的 key,也会报错
比直接用 { [key: string]: string[] } 更精确,因为后者允许任何字符串作为 key,而 Record<Role, string[]> 限定了 key 必须是 Role 的成员。
Exclude<T, U> 和 Extract<T, U>
这两个是操作联合类型。
// Exclude:从 T 中排除可以赋值给 U 的类型
type T0 = Exclude<'a' | 'b' | 'c', 'a'>; // 'b' | 'c'
type T1 = Exclude<'a' | 'b' | 'c', 'a' | 'b'>; // 'c'
type T2 = Exclude<string | number | (() => void), Function>; // string | number
// Extract:从 T 中提取可以赋值给 U 的类型
type T3 = Extract<'a' | 'b' | 'c', 'a' | 'f'>; // 'a'
type T4 = Extract<string | number | (() => void), Function>; // () => void
实际场景:
// 场景:从一个大类型的字段中,选出某些类型的字段名
type StringKeys<T> = Extract<keyof T, string>;
// 这个例子可能有点抽象,看下面
interface Example {
id: number;
name: string;
description: string;
isActive: boolean;
}
// 找出所有值为 string 的字段名
type StringFields<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];
type ExampleStringFields = StringFields<Example>; // 'name' | 'description'
这个 StringFields 类型用了映射类型 + 条件类型 + 索引访问,属于”类型体操”的范畴了。先有个印象,后面会详细讲。
条件类型:类型的 if-else
条件类型语法是:
T extends U ? X : Y
如果 T 能够赋值给 U,则解析为 X,否则解析为 Y。
基础例子
// 类似 JavaScript 的三元运算符,但在类型层面
type IsString<T> = T extends string ? 'yes' : 'no';
type T0 = IsString<'hello'>; // 'yes'
type T1 = IsString<42>; // 'no'
type T2 = IsString<string>; // 'yes'(注意:string 本身也能赋值给 string)
这个例子可能看起来没啥用,但它是理解条件类型基础。
实际场景:提取 Promise 里的值类型
你有没有想过,await promise 之后的值是什么类型?TypeScript 知道—因为它内置了一个条件类型来做这件事:
// TypeScript 内置的 Awaited<T> 类型(简化版)
type MyAwaited<T> = T extends Promise<infer U> ? MyAwaited<U> : T;
// 用法
type T0 = MyAwaited<Promise<string>>; // string
type T1 = MyAwaited<Promise<Promise<number>>>; // number(嵌套 Promise 也能处理)
这里出现了 infer 关键字。它的意思是:在条件类型 extends 判断中,如果 U 是一个泛型类型(比如 Promise<U>),用 infer U 来”提取”这个类型参数。
infer 是类型体操核心技巧之一。再看一个例子:
// 从一个函数类型中提取返回值类型
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// 用法
function greet(): string { return 'hello'; }
type GreetReturn = ReturnType<typeof greet>; // string
ReturnType 是 TypeScript 的内置工具类型,你平时应该已经用过了。
分布式条件类型
这个条件比较隐蔽,但很重要。
当 T 是一个联合类型时,条件类型会分别对联合类型每个成员应用条件,然后把结果合并。
type T0 = string | number extends string ? 'yes' : 'no'; // 'no'
// 上面这个很好理解:string | number 不能赋值给 string
type T1 = (string extends string ? 'yes' : 'no') | (number extends string ? 'yes' : 'no');
// 等价于:'yes' | 'no'
当你用泛型参数时,如果传入是联合类型,条件类型会自动”分发”:
type IsString<T> = T extends string ? 'yes' : 'no';
type T2 = IsString<string | number>;
// 等价于:IsString<string> | IsString<number>
// 结果:'yes' | 'no'
这个行为有时候很有用,有时候很让人困惑。比如你想写一个类型,把联合类型中 string 都去掉:
// 利用分布式条件类型 + Exclude(Exclude 内部就是用条件类型实现的)
type RemoveString<T> = T extends string ? never : T;
// never 在联合类型中会被自动去掉
type T3 = RemoveString<string | number | boolean>; // number | boolean
never 在联合类型中于”空”,所以 string | never | number 就是 string | number。
映射类型:批量处理字段
映射类型语法类似于 JavaScript 的对象字面量 + for...in:
// 语法
{ [K in Keys]: Value }
基础例子
// 把一个类型的所有字段变成可选的(其实就是 Partial 的实现原理)
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
// 把所有字段变成只读的(Readonly 的实现原理)
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
keyof T 产生 T 的所有键联合类型。[K in keyof T] 类似于 for...in,遍历每个键。T[K] 是索引访问类型,获取键 K 对应的值类型。
实际场景:给所有字段加一个前缀
// 场景:API 响应包裹了一层 { data: ..., meta: ... }
// 你想把 data 里的字段"提升"到顶层,但避免命名冲突
type WithPrefix<T, P extends string> = {
[K in keyof T as `${P}${Capitalize<string & K>}`]: T[K];
};
interface User {
name: string;
email: string;
}
type PrefixedUser = WithPrefix<User, 'user'>;
// 结果:{ userName: string; userEmail: string; }
这里出现了几个新东西:
as子句:在映射类型中重映射键名- 模板字面量类型:
`${P}${Capitalize<string & K>}` Capitalize:TypeScript 内置的字符串操作类型,把首字母变成大写
这些特性是 TypeScript 4.1+ 引入的,让类型系统几乎变成了一种”字符串处理函数式语言”。
实际场景:深度只读
Readonly<T> 只把第一层字段变成只读,如果字段值本身是对象,那个对象还是可变。
interface Nested {
user: {
name: string;
address: {
city: string;
};
};
}
type T0 = Readonly<Nested>;
// 效果:
// {
// readonly user: {
// name: string; // 注意:这里没有 readonly
// address: {
// city: string; // 这里也没有
// };
// };
// }
如果要做”深度只读”,需要递归:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
type T1 = DeepReadonly<Nested>;
// 现在所有层级都是 readonly 了
但这个类型有个问题:如果 T[K] 是 Date、Array 这类内置对象,也会递归处理,可能导致意想不到结果。生产代码中用这个要小心,或者加一些边界判断。
泛型约束:限制输入类型
泛型约束用 extends 关键字(跟条件类型 extends 不是一回事,虽然写法一样)。
基础用法
// 不约束:任何类型都可以传
function identity<T>(arg: T): T { return arg; }
// 加约束:T 必须有 length 属性
function logLength<T extends { length: number }>(arg: T): T {
console.log(arg.length);
return arg;
}
logLength('hello'); // OK,string 有 length
logLength([1, 2, 3]); // OK,Array 有 length
logLength(42); // 编译错误:number 没有 length
实际场景:确保 key 存在于对象上
// 场景:安全地获取对象属性
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: 'Alice', age: 30 };
getProperty(user, 'name'); // 类型:string
getProperty(user, 'age'); // 类型:number
getProperty(user, 'email'); // 编译错误:Argument of type '"email"' is not assignable to parameter of type '"name" | "age"'
这个技巧在写通用工具函数时有用。它让 TypeScript 能追踪”具体访问了哪个字段”,就给出精确类型,而不是泛化 any 或 unknown。
keyof 的高级用法
keyof 产生的类型,是可以做运算。
interface User {
id: number;
name: string;
email: string;
password: string;
}
// 所有字段名
type AllKeys = keyof User; // 'id' | 'name' | 'email' | 'password'
// 可写字段(排除只读字段)
type WritableKeys<T> = {
[K in keyof T]-?: T[K] extends { -readonly [P in K]: T[K] } ? K : never;
}[keyof T];
// 这个类型比较复杂,先不展开
// 更实用的:排除某些字段后,剩下的字段名
type NonSensitiveKeys = Exclude<keyof User, 'password'>; // 'id' | 'name' | 'email'
-? 和 -readonly 是”移除修饰符”的操作符:
?表示可选,-?表示移除可选(变成必需)readonly表示只读,-readonly表示移除只读(变成可写)
// 把一个类型的所有字段变成必需的(其实就是 Required 的实现原理)
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};
一些实战案例
案例1:类型安全事件发射器
// types/event-emitter.ts
type EventMap = {
'user:login': { userId: number; timestamp: Date };
'user:logout': { userId: number };
'error': { message: string; stack?: string };
};
interface TypedEventEmitter<T extends Record<string, any>> {
on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void;
emit<K extends keyof T>(event: K, payload: T[K]): void;
}
// 用法
declare const emitter: TypedEventEmitter<EventMap>;
emitter.on('user:login', (payload) => {
// payload 的类型是 { userId: number; timestamp: Date }
console.log(payload.userId);
});
emitter.emit('user:login', { userId: 1, timestamp: new Date() });
// 如果 payload 类型不对,TypeScript 会报错
这个模式在大型应用中很有用:事件名和 payload 类型集中定义,避免了”字符串散落各处”导致的拼写错误和类型不匹配。
案例2:API 响应的类型推导
// utils/api.ts
// 场景:根据 HTTP 方法,推导响应类型
type ApiResponse<T> = {
data: T;
status: number;
message: string;
};
type GetResponse<T> = ApiResponse<T>;
type PostResponse<T> = ApiResponse<T>;
// 实际项目中,不同 HTTP 方法的响应结构可能不同
// 这里只是示意
// 更实用的:根据端点路径推导响应类型
type Endpoints = {
'/users': User[];
'/users/:id': User;
'/posts': Post[];
};
declare function fetchApi<T extends keyof Endpoints>(url: T): Promise<ApiResponse<Endpoints[T]>>;
// 用法
const users = await fetchApi('/users');
// users.data 的类型是 User[]
这个模式在写 API 客户端时很有用。你可以把后端 API 的路由和响应类型集中定义,然后让 TypeScript 在调用 fetchApi 时自动推导出正确类型。
案例3:表单验证器类型
// types/validator.ts
// 场景:一个验证器库,验证规则的类型要和字段值的类型匹配
type ValidationRule<T> = {
required?: boolean;
validate?: (value: T) => boolean;
message?: string;
};
type FormSchema<T extends Record<string, any>> = {
[K in keyof T]: ValidationRule<T[K]>;
};
// 用法
interface LoginForm {
username: string;
password: string;
rememberMe: boolean;
}
const loginSchema: FormSchema<LoginForm> = {
username: {
required: true,
validate: (value) => value.length >= 3,
message: 'Username must be at least 3 characters',
},
password: {
required: true,
// validate: (value) => value.length >= 8, // 类型正确:value 是 string
},
rememberMe: {
// required: true, // 这对 boolean 字段可能没意义,但类型上允许
},
};
这个例子中,FormSchema<T> 根据表单数据类型 T,自动生成对应验证规则类型。如果你在 LoginForm 里加了一个新字段,TypeScript 会提醒你在 loginSchema 里也加上对应验证规则。
什么时候不该做类型体操
类型体操有个风险:过度设计。
我见过有人写了一堆复杂类型,就为了让一个函数签名”看起来很通用”,结果:
- 编译时间变长(复杂类型推导是要计算)
- 报错信息难以理解(TypeScript 报错可能打出一整屏类型展开结果)
- 其他同事看不懂,不敢改
几个原则:
-
能简单就别复杂。如果
Pick<User, 'id' | 'name'>能满足需求,就别写一套通用Subset<T, K>类型。 -
类型错误比不过类型正确。如果你复杂类型导致了
any泄露(类型断言as any满天飞),那不如退一步,用简单但正确类型。 -
考虑团队接受程度。如果你在一个大型团队,不是所有人都能看懂条件类型 + 映射类型 +
infer的组合。如果写,加注释。
// 用 tsd 测试类型(示意)
import { expectType } from 'tsd';
expectType<string>(getProperty(user, 'name'));
expectType<number>(getProperty(user, 'age'));
总结
TypeScript 的类型系统是一把双刃剑。用好了,它能在编译时发现大量错误,让重构变得自信;用过了,它会变成代码负担,让简单逻辑变得难以理解。
我的建议是:
- 先掌握内置工具类型(
Partial、Pick、Omit等),它们覆盖了 80% 的场景 - 条件类型和映射类型在写通用库、组件库时更有用;业务代码里偶尔用就行
- 类型的定义应该”刚好够用”,不要追求完美
- 如果一段类型代码你需要反复看才能看懂,那它可能太复杂了
说一句:类型系统是工具,不是目标。如果你类型的唯一作用是让 tsc 不报错,但完全没有提供额外类型信息或安全检查,那可能是没必要存在。好的类型定义应该是”让正确代码写起来很自然,让错误代码写不出来”。
你在用 TypeScript 时遇到过什么让人头大类型问题?欢迎分享,也许我们可以一起想出更简洁类型方案。
种下你的想法
在花园里留下一条评论,和这篇文章一起生长。