首页 / 文章

TypeScript 类型安全模式:泛型、依赖注入以及何时这些模式能发挥作用

用于类型安全的设计模式与 TypeScript 的类型级特性:泛型、依赖注入、带标识的 ID,以及如何判断某种模式是否有助于简化复杂性。

发布者:REACTAPP.TOP阅读约 24 分钟5587 词

来源: From Design Patterns to Type-Level Architecture — A Practical Guide from 10+ Years of Production Experience

大多数团队会使用 TypeScript 来实现自动补全和拼写检测,随后才会发现它的真正价值:它能让您将设计决策编码下来,从而由编译器来强制执行这些决策。本指南将介绍在大型前端及全栈代码库中最为重要的经典面向对象模式与类型级技术,并说明如何判断某种模式是否值得采用。

可以把类型系统视为能够回答架构相关问题的工具:

  • 这些状态值的组合真的可行吗?
  • 这个 API 返回的格式会不会超出其他代码的预期?
  • ProductId 会不会被误传给需要 UserId 的函数?
  • 组件会不会被赋予相互矛盾的属性?
  • 如果新增了某种状态,所有使用该状态的代码是否都必须处理它?
  • 能否在不影响调用该实现代码的情况下更换实现?
  • 能否在不退而求其次使用any的情况下重用抽象?
  • 模式并非可以随意添加的功能,而是当重复出现的问题出现时,人们对解决方案的命名。

    四类模式以及类型系统

    传统的分类包括创建型、结构型和行为型模式;TypeScript则增加了基于泛型、联合类型和安全工具的第四类类型级技术。

                         TYPESCRIPT PATTERNS
                                    │
            ┌───────────────────────┼────────────────────────┐
            │                       │                        │
            ▼                       ▼                        ▼
       CREATIONAL              STRUCTURAL                BEHAVIORAL
            │                       │                        │
            ├─ Factory              ├─ Adapter              ├─ Strategy
            ├─ Builder              ├─ Facade               ├─ Observer
            ├─ Singleton            ├─ Decorator            ├─ Command
            └─ Abstract Factory     ├─ Repository           └─ State
                                    └─ Composition
    
                                    +
    
                        TYPESCRIPT TYPE PATTERNS
                                    │
            ┌───────────────────────┼────────────────────────┐
            │                       │                        │
            ▼                       ▼                        ▼
        Generics                Unions                  Type Safety
            │                       │                        │
            ├─ Constraints          ├─ Discriminated         ├─ Type Guards
            ├─ keyof                  Unions                ├─ Branded Types
            ├─ typeof               ├─ Result Types          ├─ Exhaustiveness
            ├─ infer                └─ State Modeling        └─ satisfies
            └─ Mapped Types
    

    实际应用中很少只使用一种模式。典型的 React 数据流会组合多种模式,而且每个边界都可以指定精确的类型:

    React Component
          │
          ▼
    Custom Hook
          │
          ▼
    Service
          │
          ▼
    Repository
          │
          ▼
    API Client
          │
          ▼
    Result<T, E>
          │
          ▼
    Discriminated Union
    

    当各种类型在整条链中流转时,TypeScript 就成了描述系统允许如何行为的规则。

    从问题出发,而非从模式出发

    一个常见的误区是朝错误的方向思考:

    "I know Factory Pattern.
    Where can I use Factory?"
    

    先选定模式只会产生没人需要的抽象概念。不如让问题来决定选择:

    What problem do I have?
            ↓
    Where is the complexity?
            ↓
    What is changing frequently?
            ↓
    What should remain stable?
            ↓
    What abstraction reduces that complexity?
            ↓
    Is a known pattern appropriate?
    

    设想一种在支付方式上设置了多重校验的支付流程:

    if (paymentMethod === "card") {
      // ...
    }
    
    if (paymentMethod === "paypal") {
      // ...
    }
    if (paymentMethod === "upi") {
      // ...
    }
    

    反射反应通常如下:

    Don’t immediately think:
    

    “这需要策略模式。”在采用该模式之前,先判断这些分支是否真的是同一份契约下的可互换算法。如果是,那么策略模式就很适用。但如果问题在于某些字段仅对某些方法有意义,那么使用能够避免无效组合的区分联合类型可能更为合适。了解这些模式类别很容易,关键在于将某个条目与特定的使用场景相匹配。

    创建型模式

    单例模式:唯一的共享实例

    单例模式确保一个类只有一个实例。私有构造函数会阻止外部通过new创建实例,而静态方法则会延迟创建该实例并将其缓存起来:

    class Logger {
      private static instance: Logger;
    
    private constructor() {}
      static getInstance(): Logger {
        if (!Logger.instance) {
          Logger.instance = new Logger();
        }
        return Logger.instance;
      }
      log(message: string) {
        console.log(message);
      }
    }
    

    调用者会直接请求共享对象,而非自行创建:

    const logger = Logger.getInstance();
    logger.log("Application started");
    

    每个消费者最终都会指向同一个对象:

                      Logger
                        │
                 getInstance()
                        │
                        ▼
                 ┌───────────┐
                 │  Logger   │
                 │ Instance  │
                 └───────────┘
                   ▲       ▲
                   │       │
              Service A  Service B
    

    合适的候选包括:

    • 日志记录功能
    • 分析管理器
    • 配置管理器
    • 部分连接管理器
    • 其他跨领域的基础设施服务

    问题在于,单例实际上是一种伪装成的全局访问机制:测试更难隔离,依赖关系在接口中消失,生命周期变得模糊,且共享的可变状态会以难以追踪的方式发生变化。在现代前端中,从 ES 模块导出的实例本身就是共享的,而依赖注入、React Context 或状态管理库则能通过清晰的连接方式提供同样的保障。

    工厂模式:隐藏具体要创建的类

    工厂模式将决定实例化哪个具体类的任务从使用者手中移开。可对比直接构造的方式:

    const payment = new StripePayment();
    

    采用委托施工方式:

    const payment = PaymentFactory.create("stripe");
    

    两个提供方都实现了同一个接口,工厂会将字符串字面量联合类型映射到对应的类。由于参数类型为 "stripe" | "paypal",不支持的名称会导致编译失败:

    interface PaymentProvider {
      pay(amount: number): Promise<void>;
    }
    
    class StripePayment implements PaymentProvider {
      async pay(amount: number) {
        console.log("Stripe:", amount);
      }
    }
    class PayPalPayment implements PaymentProvider {
      async pay(amount: number) {
        console.log("PayPal:", amount);
      }
    }
    class PaymentFactory {
      static create(
        provider: "stripe" | "paypal"
      ): PaymentProvider {
        switch (provider) {
          case "stripe":
            return new StripePayment();
          case "paypal":
            return new PayPalPayment();
        }
      }
    }
    

    从外观上看,该工厂以提供商名称作为标识:

                  PaymentFactory
                           │
              ┌────────────┴────────────┐
              │                         │
           "stripe"                  "paypal"
              │                         │
              ▼                         ▼
         StripePayment             PayPalPayment
    

    当出现以下情况时,工厂模式便显得很有用:

    • 构造过程具有合理的逻辑性
    • 多个实现共享同一份契约
    • 使用者无需了解具体的类结构
    • 各实现必须能够独立于调用方进行演化

    对于像下面这行代码这样简单的构造,它只会增加一层需要理解的层次:

    new User();
    

    抽象工厂:相关对象的系列

    抽象工厂将这一理念扩展到必须保持一致的对象组。在针对多个平台的UI工具包中,网页按钮绝不能与移动端模态框搭配使用,因此每个工厂都会生成一个风格统一的对象系列:

    interface Button {
      render(): void;
    }
    
    interface Modal {
      open(): void;
    }
    
    interface UIFactory {
      createButton(): Button;
      createModal(): Modal;
    }
    
                      UIFactory
                        │
               ┌────────┴────────┐
               ▼                 ▼
         WebUIFactory       MobileUIFactory
               │                 │
          ┌────┴────┐       ┌────┴────┐
          ▼         ▼       ▼         ▼
       Button     Modal   Button     Modal
    

    它的功能强大,但很容易过度设计。在大多数前端应用中,简单的组件组合就能以更简洁的方式实现目标。

    构建器:可控的逐步构建

    当一个对象具有许多可选设置或需要分阶段配置时,构建器能提供帮助。每个设置方法都会更新私有配置并返回 this,从而支持链式调用:

    class RequestBuilder {
      private config: RequestInit = {};
    
    setMethod(method: string) {
        this.config.method = method;
        return this;
      }
      setHeaders(headers: HeadersInit) {
        this.config.headers = headers;
        return this;
      }
      setBody(body: BodyInit) {
        this.config.body = body;
        return this;
      }
      build() {
        return this.config;
      }
    }
    

    构建请求可视为明确的步骤:

    const request = new RequestBuilder()
      .setMethod("POST")
      .setHeaders({
        "Content-Type": "application/json"
      })
      .setBody(JSON.stringify(data))
      .build();
    

    流畅的语法只是附带效果,而非目标。关键在于将复杂的结构清晰地集中在一处,这样build()就能进行验证,例如拒绝在GET请求中接收请求体。

    结构模式

    适配器:为他人API提供的稳定接口

    适配器是应用代码中最常用的模式之一。假设你的代码需要遵循这样的内部接口规范:

    interface PaymentGateway {
      pay(amount: number): Promise<void>;
    }
    

    而第三方 SDK 提供了名称不同的方法:

    class LegacyPaymentSDK {
      makePayment(value: number) {
        // third-party implementation
      }
    }
    

    一个薄的适配器实现了你的接口并转换这些调用:

    class PaymentAdapter implements PaymentGateway {
      constructor(
        private readonly sdk: LegacyPaymentSDK
      ) {}
    
    async pay(amount: number) {
        this.sdk.makePayment(amount);
      }
    }
    
    Application
         │
         ▼
    PaymentGateway
         ▲
         │
    PaymentAdapter
         │
         ▼
    Third-party SDK
    

    该应用程序现在依赖于您拥有的一项接口:

    PaymentGateway
    

    而不是针对其背后的每个供应商:

    Stripe
    PayPal
    LegacySDK
    SomeFutureProvider
    

    要支持新的服务提供商,就需要编写另一个适配器。

    外观模式:通过一次调用完成多步骤工作流

    外观模式在复杂的子系统前提供一个简单的入口点。例如登录操作可能涉及多个服务:

    Authentication
         +
    User Service
         +
    Permissions
         +
    Notification
         +
    Analytics
    

    没有外部接口,每个用于登录用户的组件都会自行协调整个流程:

    auth.login();
    user.load();
    permission.load();
    analytics.track();
    

    一层外观将那次编排包裹起来:

    class AppFacade {
      async login(username: string, password: string) {
        const token = await auth.login(username, password);
    
    const user = await userService.getUser(token);
        await permissionService.load(user);
        analytics.track("login");
        return user;
      }
    }
    

    该组件简化为仅一次调用:

    await appFacade.login(username, password);
    
                   Component
                         │
                         ▼
                     AppFacade
                         │
           ┌─────────────┼─────────────┐
           ▼             ▼             ▼
         Auth          User        Permission
        Service       Service       Service
    

    当步骤顺序很重要时,这一特性就会发挥作用。

    装饰器:通过包装添加行为

    装饰器能够在不改变原有实现的情况下添加行为,因为包装器与被包装对象拥有相同的接口。其约定如下:

    interface Logger {
      log(message: string): void;
    }
    

    一个简单的实现:

    class ConsoleLogger implements Logger {
      log(message: string) {
        console.log(message);
      }
    }
    

    一个用于保存任意 Logger 并在消息前添加时间戳的装饰器:

    class TimestampLogger implements Logger {
      constructor(
        private readonly logger: Logger
      ) {}
    
    log(message: string) {
        this.logger.log(
          `[${new Date().toISOString()}] ${message}`
        );
      }
    }
    

    包装只不过是一种构造方式而已:

    const logger = new TimestampLogger(
      new ConsoleLogger()
    );
    

    由于每个装饰器本身都是一个 Logger,因此它们会叠加使用:

    Logger
      │
      ▼
    ConsoleLogger
      │
      ▼
    TimestampLogger
      │
      ▼
    AdditionalDecorator
    

    同样的概念还有其他名称:

    • 中间件链
    • 包装函数
    • React高阶组件
    • 日志记录
    • 缓存层
    • 授权检查
    • 工具化与追踪

    行为模式

    策略模式:可互换的算法

    策略模式可以消除分支式的业务规则。以客户等级类型为例:

    type CustomerType =
      | "regular"
      | "premium"
      | "enterprise";
    

    条件实现方式将所有规则放在一个函数中:

    function calculateDiscount(
      type: CustomerType,
      price: number
    ) {
      if (type === "regular") {
        return price;
      }
    
    if (type === "premium") {
        return price * 0.9;
      }
      return price * 0.8;
    }
    

    通过 Strategy 模式,每条规则都会成为共享接口背后的一个类:

    interface DiscountStrategy {
      calculate(price: number): number;
    }
    
    class RegularDiscount implements DiscountStrategy {
      calculate(price: number) {
        return price;
      }
    }
    class PremiumDiscount implements DiscountStrategy {
      calculate(price: number) {
        return price * 0.9;
      }
    }
    class EnterpriseDiscount implements DiscountStrategy {
      calculate(price: number) {
        return price * 0.8;
      }
    }
    
                      Order
                          │
                          ▼
                  DiscountStrategy
                          │
              ┌───────────┼───────────┐
              ▼           ▼           ▼
           Regular      Premium    Enterprise
           Strategy     Strategy    Strategy
    

    现在添加新层级通常意味着要引入新策略而非修改现有逻辑,这正是开闭原则在实践中的体现。对于仅有三条简单规则的场景,使用条件判断即可;而一旦规则开始产生自身的依赖关系或测试用例,策略模式才会展现出优势。

    观察者模式:一对多通知

    观察者模式允许某个主题在状态发生变化时通知任意数量的监听者:

                     Subject
                         │
              ┌──────────┼──────────┐
              ▼          ▼          ▼
          Observer A Observer B Observer C
    

    一个小型键入式发射器会将监听器存储在Set中,当有监听器被注册时便会返回一个清理函数:

    type Listener<T> = (value: T) => void;
    
    class EventEmitter<T> {
      private listeners = new Set<Listener<T>>();
      subscribe(listener: Listener<T>) {
        this.listeners.add(listener);
        return () => {
          this.listeners.delete(listener);
        };
      }
      emit(value: T) {
        this.listeners.forEach(listener => {
          listener(value);
        });
      }
    }
    
    const emitter = new EventEmitter<string>();
    
    const unsubscribe = emitter.subscribe(message => {
      console.log(message);
    });
    emitter.emit("Hello");
    unsubscribe();
    

    那个清理函数就是生命周期管理功能。忘记调用它会导致:

    • 监听器存活时间超过其所属组件的生命周期,从而引发内存泄漏
    • 当同一个监听器被添加两次时出现重复处理
    • 过时的闭包读取旧数据
    • 组件已销毁后仍触发副作用

    在 React 中,这正是从 useEffect 回调函数中返回的内容。

    命令:以对象形式表示动作

    命令会将一个动作转换为具有统一接口的对象:

    interface Command {
      execute(): void;
    }
    

    具体的命令用于实现它:

    class SaveCommand implements Command {
      execute() {
        console.log("Saving...");
      }
    }
    
    class UndoCommand implements Command {
      execute() {
        console.log("Undo");
      }
    }
    

    将操作视为值在以下情况下非常有用:

    • 记录所发生的事情的历史
    • 撤销与重做功能
    • 队列执行
    • 重试机制
    • 延迟执行
    • 审计日志记录

    真正的撤销功能通常需要每个命令都能自行反转,因此实际应用版本往往会在 execute() 方法之外再添加一个 undo() 方法。

    User Action
        │
        ▼
     Command
        │
        ├── execute()
        │
        ▼
     Receiver
    

    数据与结构模式

    仓库:隔离数据访问

    当数据访问变得复杂时,仓库会在业务逻辑与存储或获取数据的组件之间建立一种契约关系:

    UI
     │
     ▼
    Hook / Controller
     │
     ▼
    Service
     │
     ▼
    Repository
     │
     ├── REST
     ├── GraphQL
     ├── IndexedDB
     └── Cache
    

    合同规定了该应用可以请求的内容:

    interface UserRepository {
      getUser(id: string): Promise<User>;
      getUsers(): Promise<User[]>;
    }
    

    一种实现方式是与 REST API 进行通信:

    class ApiUserRepository implements UserRepository {
      async getUser(id: string) {
        const response = await fetch(`/users/${id}`);
    
    return response.json();
      }
      async getUsers() {
        const response = await fetch("/users");
        return response.json();
      }
    }
    

    业务代码取决于接口:

    UserRepository
    

    不在交通工具上:

    fetch()
    axios()
    graphqlClient()
    

    当数据源发生变化时,这一点尤为重要:从 REST 变为 GraphQL,添加 IndexedDB 缓存,或为测试创建内存中的模拟数据。需要注意的是,response.json() 返回的是非类型化值,因此也在仓库中验证响应是合适的选择。

    组合优于继承

    在前端开发中,组合模式比任何基于继承的模式都更为重要。不必让一个组件承担所有功能:

    MegaComponent
     ├── Authentication
     ├── Table
     ├── Filters
     ├── Modal
     ├── Notifications
     ├── API calls
     └── Business logic
    

    将职责分解为具体的任务部分:

    Dashboard
     ├── Header
     ├── Sidebar
     ├── FilterPanel
     ├── DataTable
     └── NotificationPanel
    

    在 React 中,这只需通过嵌套组件即可实现:

    <Dashboard>
      <Header />
      <Sidebar />
      <MainContent />
    </Dashboard>
    

    每个组件都可以独立理解、测试和替换。如需了解更多关于拆分臃肿组件的方法,请参阅使用组合模式与插槽解决 React 属性过载问题。

    利用类型系统建模状态

    从现在开始,TypeScript 本身便成了架构工具。

    用于请求状态的区分联合类型

    一个请求会经历多个阶段,每个阶段携带不同的数据。区分联合类型为每个阶段定义独立的结构,通过 status 字段将它们关联起来:

    type RequestState =
      | {
          status: "idle";
        }
      | {
          status: "loading";
        }
      | {
          status: "success";
          data: User[];
        }
      | {
          status: "error";
          error: string;
        };
    

    开启判别函数可缩小每个分支中的类型范围:

    function render(state: RequestState) {
      switch (state.status) {
        case "idle":
          return "Nothing started";
    
        case "loading":
          return "Loading...";
        case "success":
          return state.data;
        case "error":
          return state.error;
      }
    }
    

    编译器会在每次检查后记录当前存在的字段:

    status = "success"
            ↓
    data exists
    
    status = "error"
            ↓
    error exists
    

    比较常见的布尔值与可选值模型:

    interface State {
      loading: boolean;
      data?: User[];
      error?: string;
    }
    

    没有任何东西能阻止它描述那种本不应出现的状态:

    {
      loading: true,
      data: [...],
      error: "Something failed"
    }
    

    该联盟使得此类组合难以表达。应当对有效状态进行建模,而非分散处理可选属性并依赖每个人能正确地将它们组合起来。

    显式失败的结果类型

    许多操作仅有两种结果:

    Success
       OR
    Failure
    

    Result类型会用布尔值作为区分标志来明确表示:

    type Result<T, E> =
      | {
          success: true;
          data: T;
        }
      | {
          success: false;
          error: E;
        };
    

    一个返回它的函数:

    function getUser(): Result<User, string> {
      return {
        success: true,
        data: user
      };
    }
    

    调用方在访问 data 或 error 之前必须先检查 success 的值:

    const result = getUser();
    
    if (result.success) {
      console.log(result.data);
    } else {
      console.error(result.error);
    }
    
                 Service
                    │
                    ▼
              Result<T, E>
               /       \
              /         \
             ▼           ▼
          Success      Failure
            │             │
           data          error
    

    这适用于诸如“邮箱已注册”之类的预期业务故障。而异常则适用于真正的程序错误和基础设施故障;Result类型只是让这些预期中的故障在签名中显示出来。

    泛型及其他类型级工具

    泛型保持了输入与输出之间的关联

    使用any会丢弃相关信息:

    function identity(value: any): any {
      return value;
    }
    

    类型参数会将其保留:

    function identity<T>(value: T): T {
      return value;
    }
    

    推导出的结果基于以下论点:

    const a = identity("hello");
    // string
    
    const b = identity(100);
    // number
    

    输入与输出之间的关系依然存在:

    Input T
      │
      ▼
    Function<T>
      │
      ▼
    Output T
    

    泛型还能让共享契约被重复使用。一个响应信封:

    interface ApiResponse<T> {
      data: T;
      status: number;
      message: string;
    }
    

    描述了许多有效载荷:

    type UserResponse =
      ApiResponse<User>;
    
    type ProductResponse =
      ApiResponse<Product>;
    

    限制条件:需要指定形状

    以下代码无法编译:

    function getId<T>(item: T) {
      return item.id;
    }
    

    因为没有任何信息能告诉 TypeScript T 具有 id 属性。约束条件则提供了这一保证:

    function getId<T extends { id: string }>(
      item: T
    ) {
      return item.id;
    }
    

    任何具有 id 字符串的对象均可被接受,包括额外的字段:

    getId({
      id: "123",
      name: "Hareesh"
    });
    

    请这样理解该限制条件:

    T can be anything
    BUT
    T must have id: string
    

    keyof与索引访问

    给定一个接口:

    interface User {
      id: string;
      name: string;
      age: number;
    }
    

    keyof 会返回其属性名称的并集:

    type UserKey = keyof User;
    
    "id" | "name" | "age"
    

    将 keyof 与第二个类型参数以及索引访问类型 T[K] 结合使用,可以生成一个返回类型与键相匹配的访问器:

    function getProperty<T, K extends keyof T>(
      object: T,
      key: K
    ): T[K] {
      return object[key];
    }
    
    const user = {
      id: "1",
      name: "Hareesh",
      age: 30
    };
    
    getProperty(user, "name");
    

    真正的密钥编译结果为:

    getProperty(user, "name");
    

    缺失的键会导致编译错误:

    getProperty(user, "salary");
    

    这种强大功能源自三种工具的协同作用:

    Generics
       +
    keyof
       +
    Indexed Access
    

    映射类型:转换现有类型

    映射类型通过遍历类型的键来构建新类型。起始点为:

    interface User {
      id: string;
      name: string;
      email: string;
    }
    

    你可以得出一个全部为可选参数的版本:

    type OptionalUser = {
      [K in keyof User]?: User[K];
    };
    

    在概念上等同于书写:

    {
      id?: string;
      name?: string;
      email?: string;
    }
    

    这就是像 Partial 这样的内置类型被定义的方式。

    条件类型:类型层面的决策

    条件类型会根据赋值可能性在两种类型之间进行选择:

    T extends U ? X : Y
    

    这个会解析数组元素类型,其余内容保持不变:

    type Flatten<T> =
      T extends Array<infer U>
        ? U
        : T;
    
    type A = Flatten<string[]>;
    // string
    
    type B = Flatten<number>;
    // number
    

    此时,类型系统表现得像一种小型编译时语言,因此需要格外谨慎。

    infer:提取类型的一部分

    在条件类型中,infer用于声明一个类型变量,TypeScript会通过匹配来为该变量赋值。这实际上实现了内置的ReturnType功能:

    type MyReturnType<T> =
      T extends (...args: any[]) => infer R
        ? R
        : never;
    

    将其与 typeof 结合使用,可从现有函数中推断出类型,从而确保其永远不会偏离实现方式:

    function getUser() {
      return {
        id: "1",
        name: "Hareesh"
      };
    }
    
    type User = MyReturnType<typeof getUser>;
    

    库类型定义在很大程度上依赖于此。

    先了解内置的实用类型

    在编写复杂的辅助函数之前,先了解语言自带的类型:

    Partial
    Required
    Readonly
    Pick
    Omit
    Record
    Exclude
    Extract
    NonNullable
    ReturnType
    Parameters
    InstanceType
    Awaited
    

    例如,删除敏感字段只需一行代码,而无需使用会导致不同步的重复界面:

    interface User {
      id: string;
      name: string;
      email: string;
      password: string;
    }
    
    type PublicUser =
      Omit<User, "password">;
    

    TypeScript内置的实用类型指南对这些类型进行了深入讲解。

    键集固定的Record

    Record非常适合用于查找和配置操作,尤其是当键来自字面量联合类型时:

    type Permission =
      "read" |
      "write" |
      "delete";
    
    type PermissionMap =
      Record<Permission, boolean>;
    
    const permissions: PermissionMap = {
      read: true,
      write: false,
      delete: false
    };
    

    如果省略某个权限,TypeScript会报告缺少该键。而宽泛的键类型则无法提供这样的保证:

    const permissions: Record<string, boolean>
    

    因为 Record<string, boolean> 几乎可以接受任何字符串键,所以遗漏或拼写错误往往不会被察觉。

    标识符与不可信数据的类型安全

    域标识符的专用类型

    两个标识符虽然都是字符串,但可能表示不同的含义:

    const userId: string;
    const productId: string;
    

    从结构上来看,TypeScript 无法区分它们。将 string 与虚拟属性相交会生成不同的类型:

    type UserId =
      string & {
        readonly __brand: "UserId";
      };
    
    type ProductId =
      string & {
        readonly __brand: "ProductId";
      };
    

    函数随后可以要求相应类型的标识符:

    function getUser(id: UserId) {}
    
    function getProduct(id: ProductId) {}
    

    在期望传入 UserId 的位置传入 ProductId 会导致编译错误。该品牌在运行时并不存在;你需要通过一个简单的构造函数或验证函数来生成品牌相关的值,并在同一个地方进行类型转换。从概念上来说:

    string
      │
      ├── UserId
      ├── ProductId
      ├── OrderId
      └── TransactionId
    

    在数十个标识符共享同一原始类型的庞大系统中,这种方式能发挥巨大作用。

    针对unknown输入的类型保护

    来自网络、存储或用户输入的数据应以unknown形式进入:

    const data: unknown = await response.json();
    

    使用字幕是种诱人的捷径:

    const user = data as User;
    

    改用用户自定义的类型守卫来限定该值;其 value is User 的返回类型能告知编译器 true 结果意味着什么:

    function isUser(
      value: unknown
    ): value is User {
      return (
        typeof value === "object" &&
        value !== null &&
        "id" in value &&
        "name" in value
      );
    }
    
    if (isUser(data)) {
      console.log(data.name);
    }
    

    请记住,类型在运行时会被擦除。像这样的接口:

    interface User {
      id: string;
    }
    

    不会验证 API 发送的任何内容。上述检查仅确保键存在,而不验证其类型,因此对于不可信的输入,需结合运行时模式验证器与 TypeScript 使用。

    使用 never 进行全面检查

    这是该语言中最有效的组合之一:

    Discriminated Union
            +
    never
            +
    switch
    

    以状态联合为例:

    type Status =
      | "loading"
      | "success"
      | "error";
    

    还有一个只接受 never 的辅助函数:

    function assertNever(
      value: never
    ): never {
      throw new Error(
        `Unexpected value: ${value}`
      );
    }
    

    当处理完所有成员后,默认分支的类型为 never,因此该调用可以成功编译:

    function render(status: Status) {
      switch (status) {
        case "loading":
          return "Loading";
        case "success":
          return "Success";
        case "error":
          return "Error";
        default:
          return assertNever(status);
      }
    }
    

    现在有队友扩展了这个联合体:

    Now imagine someone adds:"cancelled" to Status.
    

    默认分支会收到“cancelled”值,该值无法赋给“never”,因此在处理该情况之前构建会失败。编译器便成了会记住所有使用者的设计审查者。

    模板字面量类型

    TypeScript能够从其他类型组合出字符串字面量类型:

    type Entity =
      "user" |
      "order" |
      "product";
    
    type Event =
      `${Entity}:created` |
      `${Entity}:updated` |
      `${Entity}:deleted`;
    

    最终的并集包含所有组合:

    user:created
    user:updated
    user:deleted
    
    order:created
    order:updated
    order:deleted
    
    product:created
    product:updated
    product:deleted
    

    实际应用包括:

    • 事件名称
    • 分析事件键
    • 权限字符串
    • 路由模式
    • 功能标志名称
    • 设计系统令牌

    当值是唯一真实来源时使用 as const

    字符串数组字面量会被展开:

    const roles = [
      "admin",
      "editor",
      "viewer"
    ];
    

    它的类型是 string[]。加上 as const 后会生成一个只读的字面量元组:

    const roles = [
      "admin",
      "editor",
      "viewer"
    ] as const;
    

    紧接着就是联合类型:

    type Role =
      typeof roles[number];
    
    becomes:
    
    
    "admin" |
    "editor" |
    "viewer"
    

    只需定义一次值,无需手动维护并行并集。

    satisfies 用于已检查的配置

    satisfies 会直接针对类型验证表达式,而不会将其转换为该类型:

    type Config = {
      retries: number;
      environment:
        | "development"
        | "production";
    };
    
    const config = {
      retries: 3,
      environment: "production"
    } satisfies Config;
    

    该对象会与Config进行比对,但config.environment仍保留着字面类型"production",而普通的注解则无法做到这一点。它适用于:

    • 路由配置
    • 功能开关
    • 设计令牌
    • 静态设置对象
    • 权限映射

    无需框架的依赖注入

    在类内部创建依赖关系会使其与该依赖紧密绑定:

    class UserService {
      private api = new ApiClient();
    }
    

    通过构造函数接收它们可使类保持无关性:

    class UserService {
      constructor(
        private readonly api: ApiClient
      ) {}
    }
    

    成品交由真实客户验收:

    const service =
      new UserService(apiClient);
    

    并测试一个模拟对象:

    const service =
      new UserService(mockApiClient);
    
                    UserService
                          ▲
                          │
                     Dependency
                          │
                 ┌────────┴────────┐
                 ▼                 ▼
            ApiClient         MockApiClient
           Production            Testing
    

    这是生成可测试代码的最廉价方法之一,仅需构造函数参数即可。

    区分相似的模式

    状态模式还是区分联合模式?

    假设有一个订单生命周期:

    Draft
    Paid
    Shipped
    Cancelled
    

    一个带歧视的联合体可能正是你所需要的:

    type Order =
      | { status: "draft" }
      | { status: "paid" }
      | { status: "shipped" }
      | { status: "cancelled" };
    

    但当每个状态都具有显著的行为特征时:

    Draft
     ├── edit()
     ├── submit()
    
    Paid
     ├── refund()
     ├── ship()
    Shipped
     ├── track()
     └── deliver()
    

    状态模式可能更为合适,因为每个状态对象都会实现允许的操作。应依据每个状态所包含的复杂度来决定,而非术语本身。

    策略模式还是状态模式?

    这是面试中常见的题目。在策略模式中,客户端负责选择算法:

    Order
      │
      ▼
    Strategy
      ├── CreditCard
      ├── PayPal
      └── UPI
    

    借助 State,对象在生命周期中会改变自身的行为:

    Order
      │
      ├── Draft
      ├── Paid
      └── Shipped
    

    策略决定了任务的执行方式;状态则决定了对象在当前阶段的行为。

    工厂模式还是策略模式?

    工厂模式用于回答“应该创建哪个对象?”这一问题:

    PaymentFactory.create("stripe");
    

    策略用于回答“应执行哪种行为?”这一问题:

    new Order(discountStrategy);
    

    它们自然地结合在一起,一家工厂负责制定策略并执行工作:

    Factory
       ↓
    creates
       ↓
    Strategy
       ↓
    executes behavior
    

    在 React 中应用模式

    使用变体属性而非布尔标志

    独立的布尔值会导致诸如按钮同时具有“主要”和“危险”属性之类的不合理情况:

    interface ButtonProps {
      primary?: boolean;
      danger?: boolean;
      loading?: boolean;
    }
    

    通过变体联合,每个变体都可以声明自己的要求:

    type ButtonProps =
      | {
          variant: "primary";
          loading?: boolean;
        }
      | {
          variant: "danger";
          confirmationRequired: boolean;
        };
    

    危险变体现在必须指定 confirmationRequired。

    能够拒绝矛盾情况的组件 API

    那些渲染链接或按钮且允许可选的 href 和 onClick 属性的组件,可能会允许同时存在这两种属性,也可能会不允许其中任何一种。下面的代码片段展示了宽松版本、有区分的版本以及正确的使用方式:

    {
      href?: string;
      onClick?: () => void;
    }
    
    use:
    type ActionProps =
      | {
          type: "link";
          href: string;
        }
      | {
          type: "button";
          onClick: () => void;
        };
    
    
    Now:
    
    <Action
      type="link"
      href="/users"
    />
    

    如果链接变体提供了点击处理函数而非 href,则会被拒绝:

    <Action
      type="link"
      onClick={...}
    />
    

    支持的模式,简明说明:

    Link
    Button
    

    TypeScript 现在已成为组件架构的一部分,而非会过时的文档。

    类型安全的事务总线

    首先创建一个将事件名称与有效载荷类型关联的映射表:

    type Events = {
      "user:created": User;
      "user:deleted": UserId;
      "order:created": Order;
    };
    

    该地图上的一个总线结构通过 keyof 和 T[K] 将每个名称与其对应的数据关联起来:

    class EventBus<T extends Record<string, unknown>> {
      on<K extends keyof T>(
        event: K,
        handler: (data: T[K]) => void
      ) {
        // implementation
      }
    
    emit<K extends keyof T>(
        event: K,
        data: T[K]
      ) {
        // implementation
      }
    }
    

    正确的有效载荷可以成功编译:

    bus.emit("user:created", user);
    

    错误的选择不会:

    bus.emit("user:created", order);
    

    此处有几种工具可供选择:

    Generics
    +
    keyof
    +
    Indexed Access
    +
    Mapped Type thinking
    

    整个API层的类型

    成熟的前端通常会以这种方式分层处理数据访问:

    Component
         ↓
    Hook
         ↓
    Service
         ↓
    Repository
         ↓
    API Client
         ↓
    HTTP
    

    在每一步都会保留类型信息。存储库可以接收带有品牌标识的 ID,并返回一个 Result:

    type ApiResponse<T> = {
      data: T;
      status: number;
    };
    
    interface UserRepository {
      getUser(id: UserId):
        Promise<Result<User, ApiError>>;
    }
    

    该组件不再在每一层都处理此问题:

    any
    

    签名本身就能表明哪些方案会成功、哪些会失败,以及适用于哪些类型。

    应避免的反模式

    any 无处不在

    function process(data: any) {}
    

    any参数会关闭对其所涉及所有内容的检查。建议使用:

    function process(data: unknown) {}
    

    使用前需将其缩窄。

    将断言用作验证

    const user =
      response.data as User;
    

    as 类型转换不会进行任何检查;它只是要求编译器信任你。应在运行时对不可信的数据进行验证。

    为泛型而存在的泛型

    应避免使用如下形式的签名:

    function transform<
      T,
      U,
      V,
      R
    >(...) {}
    

    除非每个类型参数都能体现真正的关联关系,否则单独使用一次的类型参数通常是多余的。

    庞大的联合类型

    包含上百个成员的区分联合类型既难以理解,也难以进一步发展。此时可能需要采用其他抽象方式,比如嵌套联合类型,或将变化因素纳入数据结构中。

    类型层面的巧妙设计

    当某个类型的结构比其所描述的业务逻辑更复杂时,应当退一步重新审视:

    Type complexity
           │
           ▼
    Developer complexity
           │
           ▼
    Maintenance cost
    

    类型安全是有代价的。应追求在单位复杂度下最具实用性的安全保障,而非最复杂的类型。

    选择决策树

    该树将常见的需求压力与候选模式对应起来:

    PROBLEM
       │
       ├── Need to create objects?
       │       ├── Simple creation → Constructor
       │       ├── Complex creation → Builder
       │       ├── Multiple implementations → Factory
       │       └── Families of objects → Abstract Factory
       │
       ├── Need to integrate another system?
       │       └── Adapter
       │
       ├── Complex subsystem?
       │       └── Facade
       │
       ├── Add behavior without modifying object?
       │       └── Decorator
       │
       ├── Multiple interchangeable algorithms?
       │       └── Strategy
       │
       ├── Subscribers react to changes?
       │       └── Observer
       │
       ├── Need actions/history/undo?
       │       └── Command
       │
       ├── Data-access abstraction?
       │       └── Repository
       │
       ├── Complex state lifecycle?
       │       └── State / Discriminated Union
       │
       └── Type-level problem?
               ├── Reuse → Generics
               ├── Transform → Mapped Types
               ├── Decision → Conditional Types
               ├── Extract → infer
               ├── Property safety → keyof
               ├── Literal safety → as const
               ├── Contract validation → satisfies
               └── Domain safety → Branded Types
    

    将其视为讨论的起点;“从简单开始”的原则在每个层面都适用。

    首先应学习什么

    对于准备担任高级或领导职位的前端工程师而言,死记硬背所有的“四人组”设计模式是浪费时间。合理的学习顺序如下:

    核心内容:

    Discriminated Unions
    Generics
    Type Guards
    keyof
    Mapped Types
    Utility Types
    Composition
    Strategy
    Repository
    Factory
    

    强烈推荐接下来阅读:

    Result Type
    Branded Types
    Conditional Types
    infer
    Exhaustive Checking
    Dependency Injection
    Adapter
    Facade
    Observer
    Decorator
    

    值得从概念上理解:

    Builder
    Command
    State
    Abstract Factory
    Singleton
    

    日常的前端工作在很大程度上依赖于这一组合:

    Generics
    +
    Discriminated Unions
    +
    Composition
    +
    Strategy
    +
    Repository
    

    这比任何教科书中的抽象工厂模式都要好。

    经验如何改变判断标准

    初期,工程师会问“这里应该用哪种模式?”而随着在大型系统上的经验积累,问题则变为“怎样的最小抽象层次才能解决这个问题,同时又不使系统更难理解?”以模式为先的工作流程如下:

    Problem
      ↓
    Pattern
      ↓
    More classes
      ↓
    More abstractions
    

    以问题为导向的工作流程如下:

    Problem
      ↓
    Understand volatility
      ↓
    Identify boundary
      ↓
    Start simple
      ↓
    Introduce abstraction only where repetition/change justifies it
    

    当架构中的压力变得清晰时,良好的设计模式便会自然浮现;它们并非事先强加的。

    考察真正理解程度的面试问题

    “什么是工厂模式?”这类问题考查记忆能力,而那些问题则考验判断力。

    架构相关问题:

    1. 面对一个有2000行代码的React组件,你如何决定引入哪种抽象方式?
    2. 在什么情况下你会拒绝使用看似合适的模式?
    3. 如何判断某种抽象是否为时过早?
    4. 何时组合优于继承?
    5. 如何防止单例模式变成全局可变状态?

    TypeScript基础问题:

    1. 如何对具有加载、成功、错误和重试状态的请求进行建模?
    2. 如何避免React属性出现无效组合?
  • unknown、any和never之间有什么区别?
  • 在什么情况下会选择type而非interface,反之亦然?
  • keyof如何与泛型配合使用?
  • 高级 TypeScript 内容:

    1. 条件类型的具体应用场景是什么?
    2. infer的作用是什么?
    3. 什么是映射类型?
    4. 什么是分配性条件类型?
    5. 何时使用标记类型比较合适?
    6. satisfies解决了什么问题?
    7. as const如何影响推导过程?

    实际设计应用:

    1. 设计一个类型安全的事件总线。
    2. 设计一个类型安全的 API 客户端。
    3. 使用 TypeScript 设计权限系统。
    4. 设计一个支持 Stripe、PayPal以及第三方支付服务的支付抽象层。
  • 如何在不重写代码的情况下为现有的 React 应用添加仓库层?
  • 如何处理带有不同负载数据的 WebSocket 事件?
  • 如何防止在需要 UserId 的地方传入 ProductId?
  • 有一个很有启发性的问题:“请描述一次你故意选择不使用某种设计模式的情况。”一个出色的回答会解释其中的权衡:由于只需实现一次且无需防范任何变更,使用间接方式并不会降低复杂性,因此代码会保持简洁,直到出现第二个适用场景才需要改变。

    最后的思维模型

    从业务问题出发,找出复杂性的来源,将行为变化与结构变化区分开来,再借助类型安全来优化结果。

                     BUSINESS PROBLEM
                            │
                            ▼
                    Identify Complexity
                            │
                            ▼
                 What is likely to change?
                            │
                 ┌──────────┴──────────┐
                 ▼                     ▼
              Behavior              Structure
                 │                     │
            Strategy/State       Adapter/Facade
                 │                     │
                 └──────────┬──────────┘
                            ▼
                       Type Safety
                            │
            ┌───────────────┼────────────────┐
            ▼               ▼                ▼
         Generics        Unions           Utilities
            │               │                │
            ▼               ▼                ▼
         keyof          Result Type      Mapped Types
         infer          State Model      Conditional
         satisfies      Exhaustive       Record
            │               │                │
            └───────────────┼────────────────┘
                            ▼
                     SIMPLEER CODE
    

    模式作为共享词汇

    模式作为共享词汇时效果最佳:例如“这是一个适配器”、“这些行为可以互相替换”、“这些状态属于同一类别”、“给这些ID加上标记”,而最关键的是“这个抽象结构还尚未具备其应有的复杂性”。优秀的 TypeScript 应该依据系统是否具备这些特性来评判,而非其类型设计有多巧妙:

    Invalid states
         ↓
    become difficult to represent
    
    Changing implementations
         ↓
    don't break consumers
    Business rules
         ↓
    are visible in the types
    Shared behavior
         ↓
    is reusable without duplication
    Complexity
         ↓
    is isolated behind clear boundaries
    
    • 选择模式时应依据其能消除的复杂性,而非熟悉度。
    • 相较于可选字段和侥幸心理,更应优先使用并集、Result类型以及全面检查。
    • 在运行时边界处验证数据;仅靠类型无法提供足够保护。
    • 选择最能处理你所预期变化的简单抽象层。

    相关阅读

  • React设计模式:从经典面向对象到现代Hooks:介绍了Singleton、Factory、Observer等经典软件模式在React中的应用,以及HOCs、Hooks、复合组件等React特有的模式。