首页 / 文章

作为低级设计构建块的TypeScript枚举与接口

TypeScript枚举如何消除魔法字符串错误,何时选择类型或接口,以及接口、implements和结构化类型如何在低级设计中定义契约。

发布者:REACTAPP.TOP阅读约 7 分钟1599 词

来源: Day 1: (Enums and Interfaces) LLD With TS | Part 2

在类和对象之后,TypeScript 中用于低级设计的另外两种工具是枚举和接口。它们看似简单,却能保持设计的一致性:枚举限定允许的取值,接口则明确对象的契约。本文将介绍它们各自能避免的错误、如何在 type 和 interface 之间做出选择,以及结构化类型对 implements 的影响。

枚举解决的问题:分散的魔法字符串

假设您应用程序中的订单有三种状态:

PENDING
SUCCESS
REJECTED

某个模块中检测待处理订单的方式如下:

if (order.status === "PENDING") {
  // ...
}

另一个模块则用于检测订单是否已成功:

if (order.status === "SUCCESS") {
  // ...
}

一切正常,直到有新同事编写了几乎相同但使用小写的检测代码:

if (order.status === "pending") {
  // ...
}

字符串比较是区分大小写的,因此这些都是不同的值:

"PENDING" !== "pending"

该条件实际上永远不可能为真。根本原因在于到处都是手动输入的值:谁还记得它原本是 PENDING、pending 还是 Pending 呢?

一次性定义允许的值

枚举可以在一个地方声明一组固定的命名值,这样其他代码就可以通过名称来引用它们。在这里,每个成员都被明确指定了其字符串值:

enum OrderStatus {
  PENDING = "PENDING",
  SUCCESS = "SUCCESS",
  REJECTED = "REJECTED"
}

不必像此检查那样与原始字符串进行比较,

if (order.status === "PENDING") {
  // ...
}

而应与枚举成员进行比较:

if (order.status === OrderStatus.PENDING) {
  // ...
}

其他所有状态检查也适用同样的原则:

if (order.status === OrderStatus.SUCCESS) {
  // ...
}

人们只需记住成员名称,无需记确切拼写,编辑器会自动补全该名称:

OrderStatus.PENDING

成员名称中的拼写错误现在会引发编译时错误,而不再是运行时的无声不匹配。

随着代码库规模扩大为何这很重要

想象一下该状态出现在二十个地方。如果没有枚举,一致性就取决于开发人员的自律性,各种差异也会逐渐出现:

"PENDING"
"PENDING"
"pending"
"Pending"
"PENDING"

使用枚举后,所有地方都引用同一个声明:

OrderStatus.PENDING

简而言之,枚举就是一组有名称、预定义的常量。

字符串枚举比数字枚举更易于调试,因为其值在日志中仍可清晰读取。如下所示的字符串字面量联合体也能提供类似的安全性,且无需运行时代码,有些团队更喜欢这种方式。

类型与接口

对于学习指导和面试而言,只需对二者有一个简单的概念理解即可。

类型:类型的组合与建模

  • 能够灵活地组合不同类型。
  • 可处理联合、交集、元组、原始类型、映射类型等。
  • 在主要用于描述或组合数据时非常适用。
  • 字面量字符串的联合就是一个典型例子:

    type Status = "PENDING" | "SUCCESS" | "FAILED";
    

    可以是字符串或数字的标识符也是如此:

    type ID = string | number;
    

    接口:对象与类的契约

    • 主要用于描述对象的结构以及类必须遵循的契约。
    • 可通过implements语句由类实现。
    • 当多个实现需要遵守相同契约时十分适用。
    • 支持声明合并,因此同名の两个声明会合并为一个。
    interface Payment {
      amount: number;
      pay(): void;
    }
    

    经验法则

    当需要组合或建模类型时,可使用type:

    Need to compose/model a type?
            ↓
          type
    

    当需要在类或对象之间定义契约时,可以使用interface:

    Need a contract between classes/objects?
            ↓
        interface
    

    这只是一个指导原则而非强制规则:两者都可以用来描述对象的形态。

    接口用于描述对象结构

    理解接口的最简单方法就是思考对象的构成。这是一个普通的JavaScript对象:

    const student = {
      id: 101,
      name: "Rahul",
      age: 20
    };
    

    TypeScript允许你用类型别名等方式来描述这种结构:

    type Student = {
      id: number;
      name: string;
      age: number;
    };
    

    这样就可以用同一套标准来检查多个对象是否符合该结构:

    const student1: Student = {
      id: 101,
      name: "Rahul",
      age: 20
    };
    
    const student2: Student = {
      id: 102,
      name: "Aman",
      age: 21
    };
    

    随着应用程序的发展,将这种结构命名为可重用的契约,这就是接口的作用。

    作为契约的接口

    用接口表达的相同结构:

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

    现在,任何应被视为Student类型的值都必须满足该接口的要求:

    const student: Student = {
      id: 101,
      name: "Rahul",
      age: 20
    };
    

    像这里这样遗漏了某个必需的属性,

    const student: Student = {
      id: 101,
      name: "Rahul"
    };
    

    由于缺少该属性,编译器会报错:

    age
    

    实际上,接口的意思是:若要被视为这种类型,就必须满足这些约定。

    接口定义的是“什么”,而非“如何”

    这一区别是LLD的核心所在。接口规定某物必须具备什么外观或提供什么功能,而非实现方式。以支付契约为例:

    interface Payment {
      amount: number;
      pay(): void;
    }
    

    它仅保证两件事:

    Payment must have:
    → amount
    → pay()
    

    它并未说明pay()的具体实现方式。某个类可能会从必需的amount属性开始处理卡片支付,

    class CreditCardPayment implements Payment {
      amount: number;
    

    并自行实现pay()函数:

      pay() {
        console.log("Paying using credit card");
      }
    }
    

    另一个类则可能使用印度的即时支付系统UPI来完成支付,但仍需遵循相同的属性要求。

    class UPIPayment implements Payment {
      amount: number;
    

    以及不同的 pay() 方法:

      pay() {
        console.log("Paying using UPI");
      }
    }
    

    相同的契约,不同的实现方式,这样调用者就可以使用任何支付方式:

    Interface
       ↓
    Defines the contract
       ↓
    Multiple implementations
       ↓
    Different implementation details
    

    implements 关键字

    implements 能明确体现这种关联关系。基于该契约,

    interface Payment {
      amount: number;
      pay(): void;
    }
    

    某个类会声明自己符合该契约:

    class CreditCardPayment implements Payment {
      amount: number;  pay() {
        console.log("Paying with credit card");
      }
    }
    

    该子句

    implements Payment
    

    要求编译器验证该类是否满足 Payment 契约。如果不符合,比如因为缺少 pay() 方法,

    class CreditCardPayment implements Payment {
      amount: number;
    }
    

    编译器会标记缺失的元素

    pay()
    

    implements方法仅用于编译时检查,运行时不会产生任何影响。在测试这些代码片段时需要注意一点:当启用strictPropertyInitialization选项后,像amount: number;这样没有初始化器或构造函数赋值的字段本身就会构成错误,因此必须为它指定一个值。

    结构化类型:形状优于声明

    与Java或C#不同,TypeScript是结构化类型的。类无需进行声明

    implements Payment
    

    即可在期望接收Payment的对象处被接受。考虑一个只包含单个方法的契约:

    interface Payment {
      pay(): void;
    }
    

    以及一个从未提及该类型的类:

    class UPIPayment {
      pay() {
        console.log("Paying through UPI");
      }
    }
    

    即便没有编写

    implements Payment
    

    一个期望接收Payment的函数

    function processPayment(payment: Payment) {
      payment.pay();
    }
    

    它依然可以愉快地接受该类的实例:

    const payment = new UPIPayment();processPayment(payment);
    

    这段代码能够编译是因为UPIPayment具有所需的结构:

    {
      pay(): void;
    }
    

    对于TypeScript而言,只要结构匹配即可;显式声明是可选的。

    那为什么还要写implements呢?

    既然结构化类型已经能够识别出结构匹配的类,implements似乎就显得多余了。它的价值在于会在定义类的地方检查契约,而不仅仅是在使用该类的地方进行检查。根据这个契约

    interface Payment {
      amount: number;
      pay(): void;
    }
    

    以及一个明确实现了该契约的类,

    class UPIPayment implements Payment {
      amount = 100;  pay() {
        console.log("Paying through UPI");
      }
    }
    

    假设后来有人为该接口添加了货币相关的功能:

    interface Payment {
      amount: number;
      currency: string;
      pay(): void;
    }
    

    编译器会立即指出UPIPayment不再符合契约要求,并直接标明是哪个类出了问题。如果没有这一机制,错误可能只会出现在某个较远的调用位置,或者根本不会出现。

    关键要点

    • 枚举通过一次声明即可替代分散在各处的魔法字符串,从而将拼写错误转化为编译错误。
    • 当不需要运行时对象时,字符串字面量联合是一种轻量级的替代方案。
    • 用于组合(联合、交集、映射类型)时使用type,用于类需实现的契约时则使用interface。
    • 接口描述对象提供了什么功能,而非实现方式,这使得不同的实现可以相互替换。
    • TypeScript会从结构层面检查类型,因此implements是可选的,但它能将契约错误集中在类定义处,便于修复。