复合类型
最直观的创造新的复合类型的方式,就是直接将多个类型组合在一起。比如平面上的点都有 X 和 Y 两个坐标,各自都属于 number 类型。因此可以说,平面上的点是由两个 number 类型组合成的新类型。
通常来说,将多个类型直接组合在一起形成新的类型,这样的类型最终的取值范围,就是全部成员类型所有可能的组合值的集合。
元组
假如我们需要一个函数来计算两个点之间的距离,可以这样实现:1
2
3function distance(x1: number, y1: number, x2: number, y2: number): number {
return Math.sqrt((x1 - x1) ** 2 + (y1 - y2) ** 2)
}
上述实现能够正常工作,但并不算完美。x1
在没有对应的 Y 坐标一起出现的情况下,是没有任何实际含义的。同时在应用的其他地方,我们很可能也会遇到很多针对坐标点的其他操作,因此相对于将 X 坐标和 Y 坐标独立地进行表示和传递,我们可以将两者组合在一起,成为一个新的元组类型。
元组能够帮助我们将单独的 X 和 Y 坐标组合在一起作为“点”对待,从而令代码更方便阅读和书写。1
2
3
4
5
6type Point = [number, number]
function distance(point1: Point, point2: Point): number {
return Math.sqrt(
(point1[0] - point2[0]) ** 2 + (point1[1] - point2[1]) ** 2);
}
DIY 元组
大部分语言都提供了元组作为内置语法,这里假设在标准库里没有元组的情况下,如何自己实现包含两个元素的元组类型:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class Pair<T1, T2> {
m0: T1;
m1: T2;
constructor(m0: T1, m1: T2) {
this.m0 = m0;
this.m1 = m1;
}
}
type Point = Pair<number, number>;
function distance(point1: Point, point2: Point): number {
return Math.sqrt(
(point1.m0 - point2.m0) ** 2 + (point1.m1 - point2.m1) ** 2);
}
Record 类型
将坐标点定义为数字对,是可以正常工作的。但是我们也因此失去了在代码中包含更多含义的机会。在前面的例子中,我们假定第一个数字是 X 坐标,第二个数字是 Y 坐标。但最好是借助类型系统,在代码中编入更精确的含义。从而彻底消除将 X 错认为是 Y 或者将 Y 错认为是 X 的机会。
可以借助 Record 类型来实现:1
2
3
4
5
6
7
8
9
10
11
12
13
14class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
function distance(point1: Point, point2: Point): number {
return Math.sqrt(
(point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2);
}
首要的原则是,最好优先使用含义清晰的 Record 类型,它包含的元素是有明确的命名的。而不是直接将元组传来传去。元组并不会为自己的元素提供名称,只是靠数字索引访问,因而会存在很大的误解的可能性。当然另一方面,元组是内置的,而 Record 类型通常需要额外进行定义。但大多数情况下,这样的额外工作是值得的。
维持不可变性
类的成员函数和成员变量可以被定义为 public
(能够被公开访问),也可以被定义为 private
(只允许内部访问)。在 TypeScript 中,成员默认都是公开的。
通常情况下我们定义 Record 类型,如果其成员变量是独立的,比如之前的 Point,X 坐标和 Y 坐标都可以独立的进行修改,不会影响到对方。且它们的值可以在不引起问题的情况下变化。像这样的成员被定义成公开的一般不会出现问题。
但是也存在另外一些情况。比如下面这个由 dollar
值和 cents
值组成的 Currency 类型:
- dollar 值必须是一个大于或者等于 0 的整数
- cent 值也必须是一个大于或者等于 0 的整数
- cent 值不能大于 99,每 100 cents 都必须转换成 1 dollar
如果我们允许 dollars
和 cents
变量被公开访问,就有可能导致出现不规范的对象:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21class Currency {
dollars: number;
cents: number;
constructor(dollars: number, cents: number) {
if (!Number.isSafeInteger(cents) || cents < 0)
throw new Error();
dollars = dollars + Math.floor(cents / 100);
cents = cents % 100;
if (!Number.isSafeInteger(dollars) || dollars < 0)
throw new Error();
this.dollars = dollars;
this.cents = cents;
}
}
let amount: Currency = new Currency(5, 50);
amount.cents = 300; // 由于属性是公开的,外部代码可以直接修改。从而产生非法对象
上述情况可以通过将成员变量定义为 private
来避免。同时为了维护方便,一般还需要提供公开的方法对私有的属性进行修改。这些方法通常会包含一定的验证规则,确保修改后的对象状态是合法的。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32class Currency {
private dollars: number = 0;
private cents: number = 0;
constructor(dollars: number, cents: number) {
this.assignDollars(dollars);
this.assignCents(cents);
}
getDollars(): number {
return this.dollars;
}
assignDollars(dollars: number) {
if (!Number.isSafeInteger(dollars) || dollars < 0)
throw new Error();
this.dollars = dollars;
}
getCents(): number {
return this.cents;
}
assignCents(cents: number) {
if (!Number.isSafeInteger(cents) || cents < 0)
throw new Error();
this.assignDollars(this.dollars + Math.floor(cents / 100));
this.cents = cents % 100;
}
}
外部代码只能通过 assignDollars()
和 assignCents()
两个公开的方法,对私有的属性 dollars
和 cents
进行修改。同时这两个方法也会确保对象的状态一直符合我们定义的规则。
另外一种观点是,可以将属性定义成不可变(只读)的。这样属性就可以直接被外部访问,因为只读属性会阻止自身被修改。从而对象状态保持合法。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18class Currency {
readonly dollars: number;
readonly cents: number;
constructor(dollars: number, cents: number) {
if (!Number.isSafeInteger(cents) || cents < 0)
throw new Error();
dollars = dollars + Math.floor(cents / 100);
cents = cents % 100;
if (!Number.isSafeInteger(dollars) || dollars < 0)
throw new Error();
this.dollars = dollars;
this.cents = cents;
}
}
不可变对象还有一个优势,从不同的线程对这类数据并发地访问是保证安全的。可变性会导致数据竞争。
但其劣势在于,每次我们需要一个新的值,就必须创建一个新的实例,无法通过修改现有对象得到。而创建新对象有时候是很昂贵的操作。
最终的目的在于,阻止外部代码直接修改属性,以至于跳过验证规则。可以将属性变为私有,对属性的访问完全通过包含验证规则的公开方法;也可以将属性声明为不可变的,在构造对象时执行验证。
either-or 类型
either-or 是另外一种基础的将类型组合在一起的方式,即某个值有可能是多个类型所有合法取值中的任何一个。比如 Rust 语言中的 Result<T, E>
,可能是成功的值 Ok(T)
,也可能是失败值 Err(E)
。
枚举
先从一个简单的例子开始,通过类型系统编码周一到周日。我们可以用 0-6 的数字来表示一周的七天,0 表示一周里的第一天。但这样表示并不理想,因为不同的工程师可能对这些数字有不同的理解。有些国家第一天是周日,有些国家第一天是周一。1
2
3
4
5
6
7function isWeekend(dayOfWeek: number): boolean {
return dayOfWeek == 5 || dayOfWeek == 6;
} // 欧洲国家判断是否是周末
function isWeekday(dayOfWeek: number): boolean {
return dayOfWeek >= 1 && dayOfWeek <= 5;
} // 美国判断是否是工作日
上述两个函数是冲突的。若 0 表示周日,则 isWeekend()
是不正确的;若 0 表示周一,则 isWeekday()
是不正确的。
其他的方案是定义一系列常量用来表示一周七天。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15const Sunday: number = 0;
const Monday: number = 1;
const Tuesday: number = 2;
const Wednesday: number = 3;
const Thursday: number = 4;
const Friday: number = 5;
const Saturday: number = 0;
function isWeekend(dayOfWeek: number): boolean {
return dayOfWeek == Saturday || dayOfWeek == Sunday;
}
function isWeekday(dayOfWeek: number): boolean {
return dayOfWeek >= Monday && dayOfWeek <= Friday;
}
现在的实现看上去好了一些,但仍有问题。单看函数的签名,无法清楚的知道 number
类型的参数的期待值具体是什么。假如一个新接手代码的人刚看到 dayOfWeek: number
,他可能不会意识到存在 Sunday
这类常量在某个模块的某处。因而他们会倾向于自己解释此处的数字。甚至一些人会传入非法的数字参数比如 -1
或 10
。
更好的方案是借助枚举类型。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19enum DayOfWeek {
Sunday,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday
}
function isWeekend(dayOfWeek: DayOfWeek): boolean {
return dayOfWeek == DayOfWeek.Saturday
|| dayOfWeek == DayOfWeek.Sunday;
}
function isWeekday(dayOfWeek: DayOfWeek): boolean {
return dayOfWeek >= DayOfWeek.Monday
&& dayOfWeek <= DayOfWeek.Friday;
}
Optional 类型
假设我们需要将一个用户输入的 string
值转换为 DayOfWeek
,若该 string
值是合法的,则返回对应的 DayOfWeek
;若该 string
值非法,则显式地返回 undefined
。
在 TypeScript 中,可以通过 |
类型操作符来实现,|
允许我们组合多个类型。
1 | function parseDayOfWeek(input: string): DayOfWeek | undefined { |
上述 parseDayOfWeek()
函数返回一个 DayOfWeek
或者 undefined
。useInput()
函数在调用 parseDayOfWeek()
后再对返回值进行解包操作,输出错误信息或者得到合法值。
Optional 类型:也常被叫做 Maybe 类型,表示一个可能存在的 T 类型值。一个 Optional 类型的实例,可能会包含一个 T 类型的任意值;也可能是一个特殊值,用来表示 T 类型的值不存在。
DIY Optional
1 | class Optional<T> { |
Optional 类型的优势在于,直接使用 null
空类型非常容易出错。因为判断一个变量什么时候能够为空或者不能为空是非常困难的,我们必须在所有代码中添加非空检查,否则就会有引用指向空值的风险,进一步导致运行时错误。
Optional 背后的逻辑在于,将 null
值从合法的取值范围中解耦出来。Optional 明确了哪些变量有可能为空值。类型系统知晓 Optional 类型(比如 DayOfWeek | undefined
,可能为空)和对应的非空类型(DayOfWeek
)是不一样的。两者是不兼容的类型,因而我们不会将 Optional 类型及其非空类型相混淆,在需要非空类型的地方错误地使用有可能为空值的 Optional。一旦需要取出 Optional 中包含的值,就必须显式地进行解包操作,对空值进行检查。
Result or error
现在尝试扩展前面的 DayOfWeek
例子。当 DayOfWeek
值无法正常识别时,我们不是简单地返回 undefined
,而是输出包含更多内容的错误信息。
常见的一个反模式就是同时返回 DayOfWeek
和错误码。
1 | enum InputError { |
上述实现并不是理想的,原因在于,一旦我们忘记了检查错误代码,没有任何机制阻止我们继续使用 DayOfWeek
值。即便错误代码表明有问题出现,我们仍然可以忽视该错误并直接取用 DayOfWeek
。
将类型看作值的集合,则上述 Result
类型实际上是 InputError
和 DayOfWeek
所有可能值的组合。
我们应该实现一种 either-or 类型,返回值要么是错误类型,要么是合法的值。
DIY Either
Either
类型包含了 TLeft
和 TRight
另外两种类型。TLeft
用来存储错误类型,TRight
保存合法的值。
1 | class Either<TLeft, TRight> { |
借助上面的 Either
实现,我们可以将 parseDayOfWeek()
更新为返回 Either<InputError, DayOfWeek>
。若函数返回 InputError
,则结果中就不会包含 DayOfWeek
;若函数返回 DayOfWeek
,就可以肯定没有错误发生。
当然,我们需要显式地将结果(或 Error)从 Either
中解包出来。
1 | enum InputError { |
当错误本身并不是“异常的”(大部分情况下,处理用户输入的时候),或者调用某个会返回错误码的系统 API,我们并不想直接抛出异常,但仍旧需要传递正确值或者错误码这类信息。这些时候,最好将这类信息编码到 either value or error 中。