首页 / 文章

掌握 TypeScript 的内置实用类型,让代码更简洁

了解 TypeScript 中的 Partial、Pick、Omit 和 Record 等工具类型如何消除重复接口,并自动保持类型定义的一致性。

发布者:REACTAPP.TOP阅读约 8 分钟1816 词

来源: TypeScript Utility Types You Should Be Using Daily

停止复制粘贴接口:利用 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——这三个派生类型会自动更新,无需手动修改。

快速参考总结

将类型视为手动复制的副本,会导致每次架构变更都成为维护负担。实用类型允许你只需定义一次业务模型,让编译器从该定义中自动生成所有所需的变体。这样不仅能编写更简洁的代码,还能更安全地进行重构,同时减少在处理几乎相同的接口上花费的时间,让你能专注于功能开发。