TypeScript 高级类型:条件类型、映射类型与模板字面量类型的魔法
深入探索 TypeScript 最强大的类型特性——条件类型、映射类型、infer 关键字和模板字面量类型。这些是你在实际项目中真正会用到的模式。
大部分 TypeScript 开发者都会在同一个地方遇到瓶颈。他们熟悉接口、泛型、联合类型,能给 React 组件写类型,能消除编辑器里的红色波浪线。但他们把类型系统当作一种必要之恶——一种为了防止 bug 而存在的东西,而不是能主动帮助他们设计更好软件的工具。
我在这个瓶颈停留了大约两年。让我突破的关键认知是:TypeScript 的类型系统本身就是一门编程语言。它有条件判断、循环、模式匹配、递归和字符串操作。一旦你内化了这一点,一切都会改变。你不再与编译器对抗,而是开始与它协作。
这篇文章涵盖了我在生产代码中经常使用的类型级别特性。不是学术练习——而是真正帮我避免了真实 bug 的实战模式。
类型级编程的思维方式#
在深入语法之前,我想重新构建你对类型的思考方式。
在值级编程中,你编写在运行时转换数据的函数:
function extractIds(users: User[]): number[] {
return users.map(u => u.id);
}在类型级编程中,你编写在编译时转换其他类型的类型:
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];心智模型是一样的——输入、转换、输出。区别在于类型级代码在编译期间运行,而不是在运行时。它不会产生任何 JavaScript。它唯一的目的是让不可能的状态无法表示。
如果你曾经写过一个函数,其中四个参数中有三个只在某些组合下才有意义,或者返回类型取决于传入的参数,那你就已经需要类型级编程了。你只是可能不知道 TypeScript 能表达这些。
条件类型#
条件类型是类型系统中的 if/else。语法看起来像三元表达式:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
type C = IsString<string>; // true这里的 extends 关键字不表示继承。它的意思是"可赋值给"。可以理解为"T 是否符合 string 的形状?"
构建你自己的工具类型#
让我们从头重建一些 TypeScript 内置的工具类型,来理解它们的工作原理。
NonNullable —— 从联合类型中移除 null 和 undefined:
type MyNonNullable<T> = T extends null | undefined ? never : T;
type Test = MyNonNullable<string | null | undefined>;
// 结果: stringnever 类型是"空集"——用于从联合类型中移除成员。当条件类型的某个分支解析为 never 时,联合类型中的该成员会直接消失。
Extract —— 只保留匹配约束的联合成员:
type MyExtract<T, U> = T extends U ? T : never;
type Numbers = Extract<string | number | boolean, number | boolean>;
// 结果: number | booleanExclude —— 相反的操作,移除匹配的成员:
type MyExclude<T, U> = T extends U ? never : T;
type WithoutStrings = Exclude<string | number | boolean, string>;
// 结果: number | boolean分布式条件类型#
这里开始变得有趣了,也是大多数人感到困惑的地方。当你将一个联合类型传递给条件类型时,TypeScript 会将条件分别应用于联合的每个成员。
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>;
// 你可能期望: (string | number)[]
// 实际结果: string[] | number[]TypeScript 不会计算 (string | number) extends unknown。而是分别计算 string extends unknown 和 number extends unknown,然后将结果联合起来。
这就是 Extract 和 Exclude 能正常工作的原因。当 T 是一个"裸"类型参数(没有被包裹在任何东西里)时,分布会自动发生。
如果你想阻止分布,用元组包裹两边:
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type Result = ToArrayNonDist<string | number>;
// 结果: (string | number)[]我被这个坑过的次数比我愿意承认的要多。如果你的条件类型在联合输入时给出了意外的结果,分布几乎总是原因所在。
实战示例:API 响应处理#
这是我在处理可能成功或失败的 API 响应时使用的模式:
type ApiResponse<T> =
| { status: "success"; data: T; error: never }
| { status: "error"; data: never; error: string };
type ExtractData<R> = R extends { status: "success"; data: infer D } ? D : never;
type ExtractError<R> = R extends { status: "error"; error: infer E } ? E : never;
type UserResponse = ApiResponse<{ id: number; name: string }>;
type UserData = ExtractData<UserResponse>;
// 结果: { id: number; name: string }
type UserError = ExtractError<UserResponse>;
// 结果: string这比为每种响应形状手动编写类型守卫要简洁得多。
infer 关键字#
infer 是类型的模式匹配。它允许你在条件类型内部声明一个类型变量,由 TypeScript 为你推断。
可以理解为:"我知道这个类型有某种形状。把我关心的部分提取出来。"
提取返回类型#
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // booleanTypeScript 看到 (...args) => something 这个模式,将你的函数与之匹配,并将返回类型赋给 R。
提取参数类型#
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
type Fn = (x: number, y: string) => void;
type Params = MyParameters<Fn>; // [x: number, y: string]解包 Promise#
这个我在处理异步函数时经常用到:
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnpackPromise<Promise<string>>; // string
type B = UnpackPromise<Promise<number[]>>; // number[]
type C = UnpackPromise<string>; // string (透传)对于深层嵌套的 Promise:
type DeepUnpackPromise<T> = T extends Promise<infer U>
? DeepUnpackPromise<U>
: T;
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// 结果: number提取数组元素类型#
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Items = ElementOf<string[]>; // string
type Mixed = ElementOf<(string | number)[]>; // string | number在一个类型中使用多个 infer#
你可以多次使用 infer 来提取不同的部分:
type FirstAndLast<T extends unknown[]> =
T extends [infer First, ...unknown[], infer Last]
? { first: First; last: Last }
: never;
type Result = FirstAndLast<[1, 2, 3, 4]>;
// 结果: { first: 1; last: 4 }实战:类型安全的事件提取#
这是我在事件驱动系统中使用的模式:
type EventMap = {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"order:created": { orderId: string; total: number };
"order:shipped": { orderId: string; trackingId: string };
};
type EventName = keyof EventMap;
type EventPayload<E extends EventName> = EventMap[E];
// 提取所有用户相关事件
type UserEvents = Extract<EventName, `user:${string}`>;
// 结果: "user:login" | "user:logout"
// 仅获取用户事件的载荷
type UserEventPayloads = EventPayload<UserEvents>;
// 结果: { userId: string; timestamp: number } | { userId: string }映射类型#
映射类型允许你通过转换已有类型的每个属性来创建新的对象类型。它们是类型系统中的 map()。
基础用法#
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Partial<T> = {
[K in keyof T]?: T[K];
};keyof T 给出所有属性键的联合。[K in ...] 遍历每一个。T[K] 是该属性的类型。
从零构建工具类型#
Required —— 使所有属性成为必需:
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};-? 语法移除可选修饰符。类似地,-readonly 移除只读修饰符。
Pick —— 选择特定属性:
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
interface User {
id: number;
name: string;
email: string;
password: string;
}
type PublicUser = MyPick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }Omit —— 移除特定属性:
type MyOmit<T, K extends keyof T> = {
[P in Exclude<keyof T, K>]: T[P];
};
type SafeUser = MyOmit<User, "password">;
// { id: number; name: string; email: string }Record —— 创建具有特定键和值类型的对象类型:
type MyRecord<K extends string | number | symbol, V> = {
[P in K]: V;
};
type StatusMap = MyRecord<"active" | "inactive" | "banned", boolean>;
// { active: boolean; inactive: boolean; banned: boolean }使用 as 进行键重映射#
TypeScript 4.1 引入了键重映射,允许你转换属性名:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person {
name: string;
age: number;
email: string;
}
type PersonGetters = Getters<Person>;
// {
// getName: () => string;
// getAge: () => number;
// getEmail: () => string;
// }你也可以通过将键重映射为 never 来过滤属性:
type OnlyStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type StringProps = OnlyStrings<Person>;
// { name: string; email: string }这非常强大。你同时在遍历属性、过滤它们并转换它们的名称——全部在类型层面完成。
实战示例:表单验证类型#
我在多个表单库中使用过这个模式:
interface FormFields {
username: string;
email: string;
age: number;
bio: string;
}
type ValidationErrors<T> = {
[K in keyof T]?: string[];
};
type TouchedFields<T> = {
[K in keyof T]?: boolean;
};
type DirtyFields<T> = {
[K in keyof T]?: boolean;
};
type FormState<T> = {
values: T;
errors: ValidationErrors<T>;
touched: TouchedFields<T>;
dirty: DirtyFields<T>;
isValid: boolean;
isSubmitting: boolean;
};
// 现在你的表单状态根据字段完全类型化
type MyFormState = FormState<FormFields>;在 FormFields 中添加一个字段,所有相关类型都会自动更新。移除一个字段,编译器会捕获每一个引用。这就是那种能预防整类 bug 的设计。
模板字面量类型#
模板字面量类型允许你在类型层面操作字符串。它们使用与 JavaScript 模板字面量相同的反引号语法,但用于类型。
基础字符串操作#
type Greeting = `Hello, ${string}`;
const a: Greeting = "Hello, world"; // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world"; // Error!TypeScript 还提供了内置的字符串操作类型:
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"HELLO">; // "hello"
type Cap = Capitalize<"hello">; // "Hello"
type Uncap = Uncapitalize<"Hello">; // "hello"事件名称模式#
这是模板字面量类型真正大放异彩的地方。这是我用于类型安全事件系统的模式:
type EventNames<T extends string> = `${T}Changed` | `${T}Deleted` | `${T}Created`;
type ModelEvents = EventNames<"user" | "order" | "product">;
// "userChanged" | "userDeleted" | "userCreated"
// | "orderChanged" | "orderDeleted" | "orderCreated"
// | "productChanged" | "productDeleted" | "productCreated"两个类型参数生成九个事件。而且全都是类型安全的。
CSS-in-TypeScript 模式#
模板字面量类型可以在类型层面强制 CSS 模式:
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
function setWidth(value: CSSValue): void {
// 实现
}
setWidth("100px"); // OK
setWidth("2.5rem"); // OK
setWidth("50%"); // OK
// setWidth("100"); // Error: 不是有效的 CSS 值
// setWidth("big"); // Error使用模板字面量解析字符串#
你可以将模板字面量类型与 infer 结合来解析字符串:
type ParseRoute<T extends string> =
T extends `${infer _Start}:${infer Param}/${infer Rest}`
? Param | ParseRoute<`/${Rest}`>
: T extends `${infer _Start}:${infer Param}`
? Param
: never;
type RouteParams = ParseRoute<"/users/:userId/posts/:postId">;
// 结果: "userId" | "postId"这就是像 tRPC 这样的类型安全路由库的基础。字符串 /users/:userId/posts/:postId 在类型层面被解析以提取参数名称。
结合模板字面量与映射类型#
这是真正强大的地方:
type EventHandlers<T extends Record<string, unknown>> = {
[K in keyof T as `on${Capitalize<string & K>}Change`]: (
newValue: T[K],
oldValue: T[K]
) => void;
};
interface ThemeConfig {
color: string;
fontSize: number;
darkMode: boolean;
}
type ThemeHandlers = EventHandlers<ThemeConfig>;
// {
// onColorChange: (newValue: string, oldValue: string) => void;
// onFontSizeChange: (newValue: number, oldValue: number) => void;
// onDarkModeChange: (newValue: boolean, oldValue: boolean) => void;
// }从一个简单的配置接口,你就得到了参数类型完全正确的类型化事件处理器。这就是那种真正节省开发时间的类型级代码。
进阶:点记法路径类型#
这是一个更高级的模式——生成嵌套对象所有可能的点记法路径:
type DotPaths<T, Prefix extends string = ""> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? `${Prefix}${K}` | DotPaths<T[K], `${Prefix}${K}.`>
: `${Prefix}${K}`;
}[keyof T & string]
: never;
interface Config {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
server: {
port: number;
};
}
type ConfigPaths = DotPaths<Config>;
// "database" | "database.host" | "database.port"
// | "database.credentials" | "database.credentials.username"
// | "database.credentials.password" | "server" | "server.port"现在你可以编写一个 getConfig 函数,自动补全每个可能的路径并为每个路径返回正确的类型:
type GetValueByPath<T, P extends string> =
P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? GetValueByPath<T[Key], Rest>
: never
: P extends keyof T
? T[P]
: never;
function getConfig<P extends ConfigPaths>(path: P): GetValueByPath<Config, P> {
// 实现
const keys = path.split(".");
let result: unknown = config;
for (const key of keys) {
result = (result as Record<string, unknown>)[key];
}
return result as GetValueByPath<Config, P>;
}
const host = getConfig("database.host"); // type: string
const port = getConfig("database.port"); // type: number
const username = getConfig("database.credentials.username"); // type: string完整的自动补全,完整的类型安全,类型带来的运行时开销为零。
递归类型#
TypeScript 支持递归类型定义,这让你能处理任意嵌套的结构。
深度 Partial#
内置的 Partial<T> 只让顶层属性变为可选。对于嵌套对象,你需要递归:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object
? T[K] extends Array<infer U>
? Array<DeepPartial<U>>
: DeepPartial<T[K]>
: T[K];
};
interface Settings {
theme: {
colors: {
primary: string;
secondary: string;
};
fonts: {
heading: string;
body: string;
};
};
notifications: {
email: boolean;
push: boolean;
};
}
type PartialSettings = DeepPartial<Settings>;
// 每个嵌套属性现在都是可选的注意数组检查:没有它,数组会被当作对象处理,其数字索引会变成可选的,这不是你想要的。
深度 Readonly#
同样的模式,不同的修饰符:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: T[K] extends Array<infer U>
? ReadonlyArray<DeepReadonly<U>>
: DeepReadonly<T[K]>
: T[K];
};Function 检查很重要——没有它,函数属性的内部结构会被加上 readonly,这没有意义。
JSON 类型#
这是一个经典的递归类型——表示任何有效的 JSON 值:
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
function parseJson(input: string): JsonValue {
return JSON.parse(input);
}
function isJsonObject(value: JsonValue): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}这比用 any 来处理 JSON 解析要好得多。它告诉调用者"你会得到某些东西,但你需要先收窄类型才能使用它。"这恰好是事实。
嵌套菜单的递归类型#
一个实际的例子——为导航菜单结构定义类型:
interface MenuItem {
label: string;
href: string;
icon?: string;
children?: MenuItem[];
}
type Menu = MenuItem[];
const navigation: Menu = [
{
label: "Products",
href: "/products",
children: [
{
label: "Software",
href: "/products/software",
children: [
{ label: "IDE", href: "/products/software/ide" },
{ label: "CLI Tools", href: "/products/software/cli" },
],
},
{ label: "Hardware", href: "/products/hardware" },
],
},
{ label: "About", href: "/about" },
];递归的 children 属性为你提供了无限嵌套并带有完整类型安全的能力。
实战模式#
让我分享一些我在生产系统中实际使用过的模式。
带类型的建造者模式#
当类型系统跟踪哪些字段已被设置时,建造者模式变得更加有用:
type RequiredKeys = "host" | "port" | "database";
type BuilderState = {
[K in RequiredKeys]?: true;
};
class DatabaseConfigBuilder<State extends BuilderState = {}> {
private config: Partial<{
host: string;
port: number;
database: string;
ssl: boolean;
poolSize: number;
}> = {};
setHost(host: string): DatabaseConfigBuilder<State & { host: true }> {
this.config.host = host;
return this as unknown as DatabaseConfigBuilder<State & { host: true }>;
}
setPort(port: number): DatabaseConfigBuilder<State & { port: true }> {
this.config.port = port;
return this as unknown as DatabaseConfigBuilder<State & { port: true }>;
}
setDatabase(db: string): DatabaseConfigBuilder<State & { database: true }> {
this.config.database = db;
return this as unknown as DatabaseConfigBuilder<State & { database: true }>;
}
setSsl(ssl: boolean): DatabaseConfigBuilder<State> {
this.config.ssl = ssl;
return this as unknown as DatabaseConfigBuilder<State>;
}
setPoolSize(size: number): DatabaseConfigBuilder<State> {
this.config.poolSize = size;
return this as unknown as DatabaseConfigBuilder<State>;
}
build(
this: DatabaseConfigBuilder<{ host: true; port: true; database: true }>
): Required<Pick<typeof this.config, "host" | "port" | "database">> &
Partial<Pick<typeof this.config, "ssl" | "poolSize">> {
return this.config as ReturnType<typeof this.build>;
}
}
// 这段代码能编译通过:
const config = new DatabaseConfigBuilder()
.setHost("localhost")
.setPort(5432)
.setDatabase("myapp")
.setSsl(true)
.build();
// 这段代码在编译时就会报错——缺少 .setDatabase():
// const bad = new DatabaseConfigBuilder()
// .setHost("localhost")
// .setPort(5432)
// .build(); // Error: 'database' is missingbuild() 方法只有在所有必需字段都被设置后才能调用。这在编译时就被捕获,而不是运行时。
类型安全的事件发射器#
这是一个载荷类型被强制执行的事件发射器:
type EventMap = Record<string, unknown>;
class TypedEventEmitter<Events extends EventMap> {
private listeners: {
[K in keyof Events]?: Array<(payload: Events[K]) => void>;
} = {};
on<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
this.listeners[event]?.forEach((listener) => listener(payload));
}
off<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): void {
const handlers = this.listeners[event];
if (handlers) {
this.listeners[event] = handlers.filter((h) => h !== listener) as typeof handlers;
}
}
}
// 使用示例
interface AppEvents {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"notification:new": { message: string; level: "info" | "warning" | "error" };
}
const emitter = new TypedEventEmitter<AppEvents>();
// 完全类型化——IDE 会自动补全事件名称和载荷形状
emitter.on("user:login", (payload) => {
console.log(payload.userId); // string
console.log(payload.timestamp); // number
});
// 类型错误:载荷不匹配
// emitter.emit("user:login", { userId: "123" });
// Error: 缺少 'timestamp'
// 类型错误:事件不存在
// emitter.on("user:signup", () => {});
// Error: "user:signup" 不在 AppEvents 中我在三个不同的生产项目中使用了这个模式。它在编译时就捕获了整类与事件相关的 bug。
可辨识联合与穷举检查#
可辨识联合可能是 TypeScript 中最有用的单一模式。结合穷举检查,它们保证你处理了每种情况:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
// 这行确保穷举检查
const _exhaustive: never = shape;
return _exhaustive;
}
}如果有人添加了一个新的形状变体(比如 "pentagon"),这个函数将无法编译,因为 never 不能被赋值。编译器强制你处理每种情况。
我用一个辅助函数进一步强化了这一点:
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
assertNever(shape, `Unknown shape kind: ${(shape as Shape).kind}`);
}
}类型安全的状态机#
可辨识联合同样能优美地建模状态机:
type ConnectionState =
| { status: "disconnected" }
| { status: "connecting"; attempt: number }
| { status: "connected"; socket: WebSocket; connectedAt: Date }
| { status: "error"; error: Error; lastAttempt: Date };
type ConnectionAction =
| { type: "CONNECT" }
| { type: "CONNECTED"; socket: WebSocket }
| { type: "DISCONNECT" }
| { type: "ERROR"; error: Error }
| { type: "RETRY" };
function connectionReducer(
state: ConnectionState,
action: ConnectionAction
): ConnectionState {
switch (state.status) {
case "disconnected":
if (action.type === "CONNECT") {
return { status: "connecting", attempt: 1 };
}
return state;
case "connecting":
if (action.type === "CONNECTED") {
return {
status: "connected",
socket: action.socket,
connectedAt: new Date(),
};
}
if (action.type === "ERROR") {
return {
status: "error",
error: action.error,
lastAttempt: new Date(),
};
}
return state;
case "connected":
if (action.type === "DISCONNECT") {
state.socket.close();
return { status: "disconnected" };
}
if (action.type === "ERROR") {
return {
status: "error",
error: action.error,
lastAttempt: new Date(),
};
}
return state;
case "error":
if (action.type === "RETRY") {
return { status: "connecting", attempt: 1 };
}
if (action.type === "DISCONNECT") {
return { status: "disconnected" };
}
return state;
default:
assertNever(state, "Unknown connection state");
}
}每个状态只拥有对该状态有意义的属性。你无法在断开连接时访问 socket。无法在已连接时访问 error。类型系统强制执行状态机的约束。
品牌类型保护领域安全#
还有一个我觉得必不可少的模式——使用品牌类型来防止混淆具有相同底层类型的值:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
function createUserId(id: string): UserId {
return id as UserId;
}
function createOrderId(id: string): OrderId {
return id as OrderId;
}
function getUser(id: UserId): Promise<User> {
// ...
}
function getOrder(id: OrderId): Promise<Order> {
// ...
}
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
getUser(userId); // OK
// getUser(orderId); // Error! OrderId 不能赋值给 UserIdUserId 和 OrderId 在运行时都是字符串。但在编译时,它们是不同的类型。你根本无法在需要用户 ID 的地方传入订单 ID。这在我使用它的每个项目中都捕获了真实的 bug。
常见陷阱#
高级类型很强大,但也有坑。以下是我从痛苦经验中学到的。
循环引用限制#
TypeScript 对类型有递归深度限制(目前大约 50 层,虽然会有变化)。如果你递归太深,你会遇到那个可怕的"类型实例化过深且可能无限"错误。
// 这会在深层嵌套对象上触发递归限制
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
// 修复:添加一个深度计数器
type DeepPartialSafe<T, Depth extends number[] = []> =
Depth["length"] extends 10
? T
: {
[K in keyof T]?: T[K] extends object
? DeepPartialSafe<T[K], [...Depth, 0]>
: T[K];
};深度计数器技巧使用一个随每次递归步骤增长的元组。当它达到你的限制时,递归就停止了。
性能影响#
复杂类型可能会显著拖慢你的编辑器和构建速度。我见过一些项目,一个过于巧妙的类型就让每次按键的反馈延迟增加了 3 秒。
警告信号:
- IDE 显示自动补全需要超过 2 秒
- 添加一个类型后
tsc --noEmit明显变慢 - 你看到"类型实例化过深"的错误
// 这太巧妙了——会产生组合爆炸
type AllCombinations<T extends string[]> = T extends [
infer First extends string,
...infer Rest extends string[]
]
? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
: "";
// 元素超过 5-6 个就不要这样做何时不该使用高级类型#
这可能是最重要的部分。高级类型应该在它们能预防 bug 或改善开发体验时使用。它们不应该被用来炫技。
不要在以下情况使用:
- 一个简单的
Record<string, unknown>就足够了 - 该类型只在一个地方使用,具体类型会更清晰
- 你花在调试类型上的时间比它能节省的时间还多
- 你的团队无法阅读或维护它
- 运行时检查更合适
在以下情况使用:
- 你有一个在多种类型中重复出现的模式(映射类型)
- 函数的返回类型取决于其输入(条件类型)
- 你正在构建一个需要自文档化的库 API
- 你想让非法状态无法表示
- 你厌倦了同类 bug 在代码审查中反复出现
// 过度工程化——不要对简单配置这样做
type Config = DeepReadonly<
DeepRequired<
Merge<DefaultConfig, Partial<UserConfig>>
>
>;
// 直接这样做就好
interface Config {
readonly host: string;
readonly port: number;
readonly debug: boolean;
}我的经验法则:如果解释一个类型所需的时间比解释它预防的 bug 还长,就简化它。
调试复杂类型#
当一个复杂类型不工作时,我使用这个辅助工具来"看到" TypeScript 解析后的结果:
// 在 IDE 悬停提示中展开类型以便检查
type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
// 用它来调试
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// 现在在 IDE 中将鼠标悬停在 Debug 上查看解析后的类型& {} 技巧强制 TypeScript 急切求值类型,而不是显示类型别名。它帮我节省了数小时的困惑。
另一个技巧——隔离并逐步测试:
// 不要一次性调试整个管道:
type Final = StepThree<StepTwo<StepOne<Input>>>;
// 拆分它:
type AfterStep1 = StepOne<Input>; // 悬停检查
type AfterStep2 = StepTwo<AfterStep1>; // 悬停检查
type AfterStep3 = StepThree<AfterStep2>; // 悬停检查总结#
- 条件类型(
T extends U ? X : Y)是类型的 if/else。注意联合类型的分布行为。 infer是模式匹配——用它从函数签名、Promise 和数组等结构中提取类型。- 映射类型(
{ [K in keyof T]: ... })遍历属性。结合as进行键重映射和过滤。 - 模板字面量类型在类型层面操作字符串。与映射类型结合,它们在 API 设计中非常强大。
- 递归类型处理嵌套结构,但需要深度限制以避免编译器爆炸。
- 可辨识联合加穷举检查是 TypeScript 中价值最高的单一模式。到处使用它们。
- 品牌类型防止混淆共享相同底层类型的值。实现简单,影响巨大。
- **不要过度工程化类型。**如果类型比它预防的 bug 更难理解,就简化它。目标是让代码库更安全,而不是赢得类型高尔夫竞赛。
TypeScript 的类型系统是图灵完备的,这意味着你可以用它做几乎任何事情。但真正的艺术在于知道什么时候应该这么做。