TypeScript 类型体操:掌握 8 个实战级高级类型技巧
在实际项目中,光靠 string、number、boolean 是撑不住复杂业务的。这篇文章整理了 8 个我在真实项目中反复用到的高级类型技巧,每一个都有完整代码和实际场景。
1. DeepReadonly —— 递归冻结对象类型
React 项目中经常需要保证 props 的不可变性,浅层 Readonly 根本不够用:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: DeepReadonly<T[K]>
: T[K];
};
// 使用场景
interface AppConfig {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
features: {
darkMode: boolean;
};
}
type FrozenConfig = DeepReadonly<AppConfig>;
// 下面这行会报错:Cannot assign to 'host' because it is a read-only property
const config: FrozenConfig = {
database: { host: "localhost", port: 5432, credentials: { username: "root", password: "xxx" } },
features: { darkMode: true },
};
config.database.host = "127.0.0.1"; // ❌ 类型报错
关键点:用 T[K] extends Function 排除函数类型,避免把方法也变成只读。
2. 带类型安全的 EventEmitter
手写 EventEmitter 很简单,但加上类型约束后,IDE 能自动补全事件名和回调参数:
type EventMap = {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"order:created": { orderId: string; amount: number };
};
class TypedEmitter<TEvents extends Record<string, any>> {
private handlers = new Map<string, Set<Function>>();
on<K extends keyof TEvents>(event: K, handler: (payload: TEvents[K]) => void) {
if (!this.handlers.has(event as string)) {
this.handlers.set(event as string, new Set());
}
this.handlers.get(event as string)!.add(handler);
return this;
}
emit<K extends keyof TEvents>(event: K, payload: TEvents[K]) {
this.handlers.get(event as string)?.forEach((fn) => fn(payload));
return this;
}
off<K extends keyof TEvents>(event: K, handler: Function) {
this.handlers.get(event as string)?.delete(handler);
return this;
}
}
const emitter = new TypedEmitter<EventMap>();
// ✅ 类型安全:IDE 自动补全 "user:login" | "user:logout" | "order:created"
emitter.on("user:login", (data) => {
console.log(data.userId, data.timestamp); // data 被推断为 { userId: string; timestamp: number }
});
// ❌ 编译报错:Argument of type '"user:sign"' is not assignable
// emitter.on("user:sign", () => {});
实战经验:这个模式在封装 WebSocket 客户端、跨组件通信、状态机事件时非常好用。
3. 深度合并两个对象类型(DeepMerge)
配置合并是常见需求,但类型层面的递归合并很多人搞不定:
type DeepMerge<T, U> = {
[K in keyof T | keyof U]: K extends keyof U
? K extends keyof T
? T[K] extends object
? U[K] extends object
? DeepMerge<T[K], U[K]>
: U[K]
: U[K]
: U[K]
: K extends keyof T
? T[K]
: never;
};
// 示例:合并默认配置和用户配置
interface DefaultConfig {
theme: {
primary: string;
background: string;
};
editor: {
fontSize: number;
tabSize: number;
};
}
interface UserOverride {
theme: {
primary: string;
accent: string; // 用户新增字段
};
}
type MergedConfig = DeepMerge<DefaultConfig, UserOverride>;
// 结果:
// {
// theme: { primary: string; accent: string }; // U 覆盖 T(但丢掉了 background)
// editor: { fontSize: number; tabSize: number };
// }
⚠️ 注意:上面的实现中,当两边都是对象时会递归合并,当 U 中某 key 对应的值不是对象时直接覆盖。如果需要更精确地保留
background,需要额外处理。
4. 类型安全的 API 响应处理
后端返回 { success: boolean; data?: T; error?: string } 时,用条件类型自动区分成功和失败:
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string };
interface User {
id: string;
name: string;
email: string;
}
type UserResponse = ApiResponse<User>;
function handleUserResponse(response: UserResponse) {
if (response.success) {
// ✅ 这里 TypeScript 知道 response.data 存在
console.log(response.data.name);
} else {
// ✅ 这里 TypeScript 知道 response.error 存在
console.error(response.error);
}
}
进阶用法:封装一个 fetch wrapper,让整个 API 层类型安全:
async function api<T>(url: string): Promise<ApiResponse<T>> {
try {
const res = await fetch(url);
if (!res.ok) {
return { success: false, error: `HTTP ${res.status}: ${res.statusText}` };
}
const data = await res.json();
return { success: true, data };
} catch (e) {
return { success: false, error: (e as Error).message };
}
}
// 使用
const userResult = await api<User>("/api/user/1");
if (userResult.success) {
// userResult.data 类型是 User
console.log(userResult.data.email);
}
5. Validate —— 编译期验证对象结构匹配接口
很多时候我们写了一个配置对象,但不确定它是否真的符合接口定义。可以用这种技巧在编译期强制检查:
type Validate<T, U extends T> = U;
interface CreateUserDTO {
email: string;
password: string;
name?: string;
age?: number;
}
// ✅ 编译通过
const validDTO = {
email: "test@example.com",
password: "secret123",
name: "Tom",
} satisfies CreateUserDTO;
// ❌ 编译报错:缺少 email
// const invalidDTO = { password: "123" } satisfies CreateUserDTO;
satisfies 是 TS 4.9+ 引入的关键字,比 as 安全一万倍。它不做类型断言,而是做类型检查,同时保留字面量类型。
6. 函数参数类型提取 + 部分应用
在封装高阶函数、中间件时,经常需要提取和变换函数参数类型:
// 提取第 N 个参数的类型
type ParameterAt<T extends (...args: any) => any, N extends number> = Parameters<T>[N];
// 移除第一个参数(常用于包装已有函数)
type RemoveFirst<T extends any[]> = T extends [any, ...infer Rest] ? Rest : never;
// 实际应用:部分应用(partial application)
function partial<F extends (...args: any) => any, First extends any[]>(
fn: F,
...firstArgs: First
): (...rest: RemoveFirst<Parameters<F>>) => ReturnType<F> {
return (...restArgs) => fn(...firstArgs, ...restArgs);
}
// 示例
function createUser(name: string, age: number, role: string) {
return { name, age, role };
}
const createAdmin = partial(createUser, "Admin", 30);
// createAdmin 的签名:(role: string) => { name: string; age: number; role: string }
const admin = createAdmin("superadmin");
// { name: "Admin", age: 30, role: "superadmin" }
7. 自动推断 API 路由参数类型
做后端框架类型封装时,路由参数的类型推断是个难点:
// 从 "/users/:id/posts/:postId" 提取出 { id: string; postId: string }
type ExtractParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param]: string } & ExtractParams<Rest>
: T extends `${string}:${infer Param}`
? { [K in Param]: string }
: {};
// 测试
type UserParams = ExtractParams<"/users/:id">;
// { id: string }
type PostParams = ExtractParams<"/users/:id/posts/:postId">;
// { id: string } & { postId: string } → { id: string; postId: string }
// 实际封装
interface RouteDefinition<TPath extends string> {
path: TPath;
method: "GET" | "POST" | "PUT" | "DELETE";
handler: (params: ExtractParams<TPath>) => Promise<any>;
}
const getUserPosts: RouteDefinition<"/users/:id/posts/:postId"> = {
path: "/users/:id/posts/:postId",
method: "GET",
handler: async (params) => {
// params.id 和 params.postId 都有类型提示
console.log(params.id, params.postId);
return {};
},
};
8. 分布式条件类型的陷阱与解决方案
这是最容易踩坑的地方。条件类型在联合类型上会「分配」,导致意想不到的结果:
// 陷阱:分布条件类型
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// 结果是 string[] | number[],而不是 (string | number)[]
// 解决方案:用 [] 包裹
type ToArrayNonDistributive<T> = [T] extends [any] ? T[] : never;
type Fixed = ToArrayNonDistributive<string | number>;
// 结果是 (string | number)[] ✅
什么时候需要关闭分配?
当你处理联合类型但不想自动展开时。比如把联合类型包装成数组,或者做整体比较时。
// 实际场景:判断一个类型是否是某个联合类型的一部分
type IsMember<T, Union> = [T] extends [Union] ? true : false;
type A = IsMember<"a", "a" | "b" | "c">; // true
type B = IsMember<"d", "a" | "b" | "c">; // false
总结
| 技巧 | 核心用途 |
|---|---|
| DeepReadonly | 递归不可变对象 |
| TypedEmitter | 类型安全的事件系统 |
| DeepMerge | 配置合并的类型安全 |
| ApiResponse 联合类型 | 条件类型窄化 |
| satisfies | 编译期结构验证 |
| Partial Application | 函数参数类型变换 |
| 路由参数推断 | 字符串模板类型提取 |
| 关闭分配 | 联合类型精确控制 |
这些不是花拳绣腿——我在中后台项目、组件库、Node.js 框架封装中反复用到它们。类型系统的价值在于:把运行时才能发现的错误,提前到编译期解决。
下次写 as any 之前,先想想是不是可以用类型体操解决 😄
评论区
登录 后参与评论