第 103 章:映射类型(Mapped Types)
学习目标
- 理解映射类型的本质和语法
- 学会内置映射类型(Partial / Required / Readonly / Pick / Omit)
- 掌握自定义映射类型
- 理解键名重映射(Key Remapping)
一、什么是映射类型
映射类型是基于已有类型创建新类型的方式,遍历键并转换。
1.1 第一个映射类型
typescript
type User = {
name: string;
age: number;
email: string;
};
// 把所有字段变成可选
type OptionalUser = {
[K in keyof User]?: User[K];
};
// 等价于
// {
// name?: string;
// age?: number;
// email?: string;
// }1.2 语法拆解
typescript
// K 是键的占位符
// ↓
{ [K in keyof T]: ... }
// ↑
// 遍历 T 的所有键
// 类比 JS:
Object.keys(obj).forEach((key) => { /* 转换 */ });二、内置映射类型
2.1 Partial<T> - 所有字段可选
typescript
type Partial<T> = {
[K in keyof T]?: T[K];
};
interface User {
id: number;
name: string;
email: string;
}
// 场景:更新时只传部分字段
function updateUser(id: number, data: Partial<User>) {
// data 的每个字段都可选
}
updateUser(1, { name: 'Tom' }); // ✅
updateUser(1, { email: 'a@b.c' }); // ✅
updateUser(1, { name: 'Tom', email: 'a@b.c' }); // ✅2.2 Required<T> - 所有字段必填
typescript
type Required<T> = {
[K in keyof T]-?: T[K];
};
interface Config {
host?: string;
port?: number;
debug?: boolean;
}
const config: Required<Config> = {
host: 'localhost', // 现在是必填
port: 3000, // 现在是必填
debug: false // 现在是必填
};2.3 Readonly<T> - 所有字段只读
typescript
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
const user: Readonly<User> = {
id: 1,
name: 'Tom',
email: 'tom@example.com'
};
user.name = 'Jerry'; // ❌ Cannot assign to 'name' because it is read-only2.4 Pick<T, K> - 挑选字段
typescript
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
// 场景:从 User 中只取展示用字段
type UserPreview = Pick<User, 'id' | 'name'>;
// { id: number; name: string }
const preview: UserPreview = { id: 1, name: 'Tom' };2.5 Omit<T, K> - 排除字段
typescript
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
// 场景:创建用户时不传 id
type CreateUserDTO = Omit<User, 'id'>;
// { name: string; email: string }
const dto: CreateUserDTO = { name: 'Tom', email: 'tom@example.com' };2.6 Record<K, V> - 构造字典
typescript
type Record<K extends keyof any, V> = {
[P in K]: V;
};
// 场景:页面权限映射
type PagePermission = Record<'home' | 'profile' | 'admin', boolean>;
// { home: boolean; profile: boolean; admin: boolean }
const perms: PagePermission = {
home: true,
profile: false,
admin: false
};
// 场景:动态键
type StringMap = Record<string, number>;
const map: StringMap = { a: 1, b: 2 };2.7 Exclude / Extract
typescript
// Exclude: 从 T 中排除 U
type Exclude<T, U> = T extends U ? never : T;
type T1 = Exclude<'a' | 'b' | 'c', 'a'>; // 'b' | 'c'
// Extract: 从 T 中提取 U
type Extract<T, U> = T extends U ? T : never;
type T2 = Extract<'a' | 'b' | 'c', 'a' | 'b'>; // 'a' | 'b'
// 实战:过滤联合类型
type Status = 'pending' | 'success' | 'error' | 'idle';
type ActiveStatus = Exclude<Status, 'idle'>; // 'pending' | 'success' | 'error'2.8 NonNullable
typescript
type NonNullable<T> = T extends null | undefined ? never : T;
type T1 = NonNullable<string | null>; // string
type T2 = NonNullable<number | undefined>; // number
type T3 = NonNullable<null | undefined>; // never2.9 ReturnType / Parameters
typescript
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
function fetchUser(id: number, name: string): User {
// ...
}
type Return = ReturnType<typeof fetchUser>; // User
type Params = Parameters<typeof fetchUser>; // [id: number, name: string]三、自定义映射类型
3.1 把所有字段变成 nullable
typescript
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
type NullableUser = Nullable<User>;
// { id: number | null; name: string | null; email: string | null }3.2 把所有字段变成 getter
typescript
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface User {
name: string;
age: number;
}
type UserGetters = Getters<User>;
// {
// getName: () => string;
// getAge: () => number;
// }3.3 过滤指定字段
typescript
// 只保留 string 类型的字段
type StringFields<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
interface Mixed {
id: number;
name: string;
email: string;
age: number;
}
type OnlyString = StringFields<Mixed>;
// { name: string; email: string }四、键名重映射(Key Remapping, TS 4.1+)
用 as 子句修改键名。
4.1 添加前缀
typescript
type WithPrefix<T, P extends string> = {
[K in keyof T as `${P}${Capitalize<string & K>}`]: T[K];
};
interface User {
name: string;
age: number;
}
type PrefixedUser = WithPrefix<User, 'user'>;
// { userName: string; userAge: number }4.2 过滤键
typescript
type FilteredKeys<T, Filter extends string> = {
[K in keyof T as K extends Filter ? K : never]: T[K];
};
interface User {
id: number;
name: string;
email: string;
}
type OnlyName = FilteredKeys<User, 'name'>; // { name: string }4.3 移除特定字段
typescript
type RemoveField<T, K extends keyof T> = {
[P in keyof T as P extends K ? never : P]: T[P];
};
type WithoutEmail = RemoveField<User, 'email'>;
// { id: number; name: string }4.4 重写键名
typescript
type RenameKey<T, OldKey extends keyof T, NewKey extends string> = {
[K in keyof T as K extends OldKey ? NewKey : K]: T[K];
};
type RenamedUser = RenameKey<User, 'name', 'fullName'>;
// { id: number; fullName: string; email: string }五、深入理解映射修饰符
5.1 +? 和 -?
typescript
// +? 加可选(默认就是 +?,所以写不写都行)
type WithOptional<T> = {
[K in keyof T]+?: T[K];
};
// -? 移除可选
type RemoveOptional<T> = {
[K in keyof T]-?: T[K];
};5.2 +readonly 和 -readonly
typescript
// +readonly 加只读
type AddReadonly<T> = {
+readonly [K in keyof T]: T[K];
};
// -readonly 移除只读
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
interface Frozen {
readonly id: number;
readonly name: string;
}
type Unfrozen = Mutable<Frozen>;
// { id: number; name: string } // 可写六、映射类型与泛型结合
6.1 通用 CRUD 类型
typescript
interface BaseEntity {
id: number;
createdAt: Date;
updatedAt: Date;
}
// 创建:不带 id 和时间戳
type CreateDTO<T> = Omit<T, 'id' | 'createdAt' | 'updatedAt'>;
// 更新:所有字段可选
type UpdateDTO<T> = Partial<Omit<T, 'id'>>;
// 列表查询:分页参数
type ListQuery<T> = Partial<T> & {
page: number;
pageSize: number;
};
// 使用
interface Article extends BaseEntity {
title: string;
content: string;
authorId: number;
}
type CreateArticleDTO = CreateDTO<Article>;
type UpdateArticleDTO = UpdateDTO<Article>;
type ListArticleQuery = ListQuery<Article>;6.2 通用响应包装
typescript
type ApiResponse<T> = {
code: number;
message: string;
data: T;
};
type PagedResponse<T> = {
code: number;
data: {
items: T[];
total: number;
};
};七、实战案例
7.1 表单字段生成器
typescript
type FormField<T> = {
[K in keyof T]: {
value: T[K];
error?: string;
touched: boolean;
};
};
interface LoginForm {
username: string;
password: string;
remember: boolean;
}
type LoginFormState = FormField<LoginForm>;
// {
// username: { value: string; error?: string; touched: boolean };
// password: { value: string; error?: string; touched: boolean };
// remember: { value: boolean; error?: string; touched: boolean };
// }7.2 EventEmitter 类型
typescript
type EventMap = {
click: { x: number; y: number };
focus: { target: string };
blur: undefined;
};
type EventHandlers<T> = {
[K in keyof T as `on${Capitalize<string & K>}`]?: (payload: T[K]) => void;
};
type Handlers = EventHandlers<EventMap>;
// {
// onClick?: (payload: { x: number; y: number }) => void;
// onFocus?: (payload: { target: string }) => void;
// onBlur?: (payload: undefined) => void;
// }7.3 Redux Action 类型
typescript
type Action<T, P = void> = P extends void
? { type: T }
: { type: T; payload: P };
type ActionMap = {
INCREMENT: number;
SET_NAME: string;
RESET: void;
};
type ActionUnion = {
[K in keyof ActionMap]: Action<K, ActionMap[K]>
}[keyof ActionMap];
// 等价于:
// { type: 'INCREMENT'; payload: number }
// | { type: 'SET_NAME'; payload: string }
// | { type: 'RESET' }八、常见错误
8.1 键名映射写错
typescript
// ❌ keyof T 不在键名位置
type Bad<T> = { [K in T]: ... }; // T 必须是联合类型
// ✅ 标准写法
type Good<T> = { [K in keyof T]: ... };8.2 映射接口会报错
typescript
// ❌ interface 不支持映射
// interface Mapped<T> { [K in keyof T]: T[K] } // ❌
// ✅ 必须用 type
type Mapped<T> = { [K in keyof T]: T[K] };8.3 过度映射
typescript
// ❌ 不需要映射时硬用
type WrapString<T> = { value: T }; // 这不是映射
// ✅ 简单的就直接写
type WrapString = { value: string };九、本章小结
| 要点 | 关键 |
|---|---|
| 映射语法 | { [K in keyof T]: T[K] } |
| Partial/Required | 可选/必填切换 |
| Readonly | 只读 |
| Pick/Omit | 挑选/排除字段 |
| Record | 构造字典 |
| 键名重映射 | as 修改键名 |
| 修饰符 | +? -? +readonly -readonly |
动手练习
- 自定义映射:写一个
Mutable<T>类型,把Readonly<T>变成可写 - 键名转换:写一个
EventEmitter的类型映射,把'click'转成'onClick' - 过滤映射:写一个
PickByType<T, V>类型,只保留值为 V 类型的字段 - 递归映射:写一个
DeepReadonly<T>,让嵌套对象也变只读
推荐阅读
- 📖 TypeScript Handbook - Mapped Types — 映射类型官方文档
- 📖 TypeScript 4.1 - Key Remapping — 键名重映射
- 🌐 Type Challenges - Mapped Types — 练习题