掌握 TypeScript 的内置实用类型,让代码更简洁
了解 TypeScript 中的 Partial、Pick、Omit 和 Record 等工具类型如何消除重复接口,并自动保持类型定义的一致性。
停止复制粘贴接口:利用 TypeScript 内置的类型转换器编写更简洁、更可靠的代码。
迟早,每位 TypeScript 开发者都会遇到同样的问题。你设计了一个包含八个字段的简洁 User 接口,几分钟后又需要一个只需两个字段的编辑表单,于是便创建了 UpdateUserInput 接口。接着又出现了需要隐藏敏感字段的 API 响应,于是又添加了 PublicUser 接口。不知不觉中,你的代码库里就出现了大量几乎相同的接口,而随着项目的发展,这些接口会逐渐出现不一致。
每当底层架构发生变化时,你就不得不同时查找并更新多个地方。如果漏掉了某个地方,就会埋下隐患,日后在正式环境中引发问题。
TypeScript通过内置的工具集Utility Types来解决这个问题。你可以把它们视为作用于类型而非值的函数:输入一个现有的类型,应用某种转换规则,就能得到一个全新的类型。这样你只需保留一个权威的定义,而将所有重复的类型重塑工作交给编译器处理。
以下是值得经常使用的实用类型,我会用通俗的语言进行解释,并提供可直接套用的实际代码示例。
1. 转换属性的必填性
这三个实用类型用于控制字段是可选的、必填的还是不可修改的。
Partial<Type>
用通俗的话来说: “将所有属性都设为可选的。”
每当你要构建类似 PATCH 接口或更新应用程序状态的一部分时,都不希望从调用方获取整个对象。Partial<T> 会遍历接口中的每个字段,并在每个字段前加上 ?。
interface UserProfile {
id: string;
name: string;
email: string;
avatarUrl: string;
theme: "light" | "dark";
}
// Without Partial, you'd have to rewrite all fields with '?'
type UpdateProfileInput = Partial<UserProfile>;
function updateProfile(userId: string, changes: UpdateProfileInput) {
// Valid: changes can include any subset of UserProfile properties
apiClient.patch(`/users/${userId}`, changes);
}
// Valid call:
updateProfile("usr_101", { theme: "dark" });
Required<Type>
用通俗的话来说: “去掉所有可选标记,确保没有内容可被跳过。”
Required<T> 的作用与 Partial<T> 相反:对于那些某些字段为可选的接口,它会强制要求所有字段都必须存在。在合并默认设置并需要确保没有内容缺失时,这一功能非常有用。
interface AppConfig {
apiUrl: string;
timeout?: number;
retries?: number;
enableLogging?: boolean;
}
// Ensure the internal runner has every single setting resolved
type ResolvedConfig = Required<AppConfig>;
const defaultConfig: ResolvedConfig = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
enableLogging: true,
};
function initializeApp(userOptions: AppConfig): ResolvedConfig {
return { ...defaultConfig, ...userOptions };
}
Readonly<Type>
用通俗的话说: “冻结所有属性,使其无法被重新赋值。”
当您希望防止意外修改——比如配置值、组件属性或共享的应用状态——可以使用 Readonly<T> 将所有属性标记为 只读。此后任何尝试写入这些键的操作都将在编译时失败。
interface SystemRole {
roleName: string;
permissions: string[];
}
const SuperAdminRole: Readonly<SystemRole> = {
roleName: "SUPER_ADMIN",
permissions: ["read", "write", "delete", "admin"],
};
// Error: Cannot assign to 'roleName' because it is a read-only property.
SuperAdminRole.roleName = "USER";
2. 提取所需部分
有很多情况下您只需要接口中的某一部分。无需手动创建全新的、无关的类型,可以直接从现有的领域模型中提取所需部分。
Pick<Type, Keys>
用通俗的话说: “仅使用原始类型中指定的这些键来构建新类型。”
假设你有一个大型模型,但某个小型 UI 小部件或轻量级数据库查询只需要名称和头像信息。你可以指示 TypeScript 仅保留这两个字段。
interface Product {
id: string;
sku: string;
name: string;
price: number;
stockQuantity: number;
supplierId: string;
description: string;
}
// Pick only the fields a mini checkout card displays
type CartItemPreview = Pick<Product, "id" | "name" | "price">;
const item: CartItemPreview = {
id: "prod_99",
name: "Wireless Mechanical Keyboard",
price: 129.99,
};
Omit<Type, Keys>
用通俗的话来说: “保留完整的原始类型,但去掉这些特定的字段。”
Omit<T, K> 是对 Pick 功能的逆向操作。在向数据库插入新记录时,通常除了系统自动生成的字段(如 id 或 createdAt)之外,其他所有字段都需要保留。
interface Article {
id: number;
title: string;
slug: string;
content: string;
publishedAt: Date;
viewCount: number;
}
// Strip metadata handled by the database
type CreateArticlePayload = Omit<Article, "id" | "viewCount" | "publishedAt">;
function submitArticle(payload: CreateArticlePayload) {
// TypeScript guarantees no one accidentally submits an 'id' or 'viewCount'
apiClient.post("/articles", payload);
}
3. 动态键映射
Record<Keys, Type>
用通俗的话说: “构建一个字典,其键来自此列表,值则需符合特定格式。”
用作查找表的普通 JavaScript 对象往往具有宽松的类型定义,类似 { [key: string]: any } 这种形式,这会破坏类型安全性。Record<Keys, Type> 则能明确规定允许的键以及值的格式要求。
type SubscriptionTier = "free" | "pro" | "enterprise";
interface TierFeatures {
monthlyQuota: number;
customDomainAllowed: boolean;
supportLevel: "community" | "email" | "dedicated";
}
// Guarantees all three tiers are explicitly handled
const subscriptionPlans: Record<SubscriptionTier, TierFeatures> = {
free: {
monthlyQuota: 1000,
customDomainAllowed: false,
supportLevel: "community",
},
pro: {
monthlyQuota: 50000,
customDomainAllowed: true,
supportLevel: "email",
},
enterprise: {
monthlyQuota: 1000000,
customDomainAllowed: true,
supportLevel: "dedicated",
},
};
如果日后在 SubscriptionTier 中新增“入门级”套餐,TypeScript 会立即标记 subscriptionPlans,并提示缺少“入门级”条目。
4. 精简与限定联合类型
上述工具用于重新构造对象键。接下来的两个工具则作用于联合类型,例如 "apple" | "banana" | "orange"。
Exclude<UnionType, ExcludedMembers>
用通俗的话来说: "取这组可能的值,然后去掉这里列出的那些值。"
type TaskStatus = "draft" | "in_progress" | "review" | "completed" | "archived";
// You can edit tasks in any status except when they are completed or archived
type EditableTaskStatus = Exclude<TaskStatus, "completed" | "archived">;
// Result: "draft" | "in_progress" | "review"
Extract<UnionType, ExtractedMembers>
用通俗的话来说: "取这组可能的值,只保留符合此条件的那些值。"
Extract 的作用与 Exclude 相反:它找出两个联合类型之间的交集,仅保留同时存在于两者中的成员。
type WindowEvents = "click" | "scroll" | "mousemove" | "keydown" | "keyup";
type PointerEvents = "click" | "mouseenter" | "mouseleave" | "mousemove";
// Keep only the events shared across both categories
type SharedEvents = Extract<WindowEvents, PointerEvents>;
// Result: "click" | "mousemove"
NonNullable<Type>
用通俗的话说,就是从类型中去除null和undefined值。
在处理来自外部API或数据库客户端的数据时,字段通常被定义为string | null | undefined类型。NonNullable<T>会剔除这些空值可能性,只保留有实际意义的类型。
type SearchQuery = string | string[] | null | undefined;
type CleanSearchQuery = NonNullable<SearchQuery>;
// Result: string | string[]
5. 从现有代码推断类型
TypeScript最能节省时间的功能之一,就是可以直接从函数、Promise以及导入的模块中获取类型,而无需手动重新声明。
ReturnType<FunctionType>
用通俗的话说:它会显示函数返回的值类型,这样你就无需再手动输入该类型了。
对于复杂的工厂函数、Redux 风格的选择器,或是那些不公开返回值结构的第三方包,你可以通过直接引用该函数本身来获取其确切的结构。
function buildSessionPayload(userId: string, roles: string[]) {
return {
sessionId: crypto.randomUUID(),
authenticatedAt: Date.now(),
expiresAt: Date.now() + 1000 * 60 * 60 * 24,
account: {
id: userId,
primaryRole: roles[0] ?? "guest",
allRoles: roles,
},
};
}
// Automatically inherits the shape of whatever buildSessionPayload returns
type UserSession = ReturnType<typeof buildSessionPayload>;
如果buildSessionPayload内部返回的对象后来发生了变化,UserSession会自动检测到这一变化。
Parameters<FunctionType>
用通俗的话来说:它会生成一个元组,列出函数所接受的每一个参数。
每当你需要封装 SDK 方法、转发参数,或为函数调用构建日志层时,Parameters<T>能确保你的封装版本的签名始终与原始版本保持一致。
function triggerAlert(message: string, severity: "low" | "high", code?: number) {
// Internal dispatch logic
}
// Extracts arguments as a tuple: [message: string, severity: "low" | "high", code?: number]
type AlertArgs = Parameters<typeof triggerAlert>;
function logAndAlert(...args: AlertArgs) {
console.log("Dispatching alert:", args[0]);
triggerAlert(...args);
}
Awaited<Type>
用通俗的话说:它负责解析 Promise 并暴露其中的值。
由于现代 TypeScript 的很多功能都围绕异步代码展开,Awaited<T>对于逐层解析嵌套的 Promise,直至获取到最终的解析值而言至关重要。
async function fetchAccountDetails() {
return {
accountId: "acct_8472",
balance: 4250.75,
currency: "USD",
};
}
// Unwraps the Promise<T> returned by the async function
type AccountDetails = Awaited<ReturnType<typeof fetchAccountDetails>>;
// Result: { accountId: string; balance: number; currency: string }
const cachedAccount: AccountDetails = {
accountId: "acct_8472",
balance: 4250.75,
currency: "USD",
};
6. 像积木一样堆叠实用类型
实用类型在组合使用时才能展现出真正的强大功能。由于每个类型都会输出标准的 TypeScript 类型,因此可以将一个类型的处理结果直接传递给另一个类型。
以基于 Customer 实体构建的数据层为例:
interface Customer {
id: string;
firstName: string;
lastName: string;
email: string;
phoneNumber?: string;
createdAt: Date;
updatedAt: Date;
}
// 1. Creation payload: No auto-generated fields, but firstName, lastName, and email are mandatory
type CreateCustomerDTO = Omit<Customer, "id" | "createdAt" | "updatedAt">;
// 2. Update payload: Omit IDs, and let the user modify ANY valid field optionally
type UpdateCustomerDTO = Partial<Omit<Customer, "id" | "createdAt" | "updatedAt">>;
// 3. Read-only view for UI caches
type ReadonlyCustomer = Readonly<Customer>;
仅用几行代码,你就创建了三个独立且经过完全类型检查的数据传输对象。如果基础Customer接口后来新增了属性——比如loyaltyTier——这三个派生类型会自动更新,无需手动修改。
快速参考总结
将类型视为手动复制的副本,会导致每次架构变更都成为维护负担。实用类型允许你只需定义一次业务模型,让编译器从该定义中自动生成所有所需的变体。这样不仅能编写更简洁的代码,还能更安全地进行重构,同时减少在处理几乎相同的接口上花费的时间,让你能专注于功能开发。