作为低级设计构建块的TypeScript枚举与接口
TypeScript枚举如何消除魔法字符串错误,何时选择类型或接口,以及接口、implements和结构化类型如何在低级设计中定义契约。
在类和对象之后,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是可选的,但它能将契约错误集中在类定义处,便于修复。