在 Typescript 中 实现 ADT(Algebra Data Types)

Algebra Data Types,代数数据类型,是一个函数式编程中非常有趣的概念,从 java 角度来看,ADT 可以认为是一种模式,它看着就像带上数据的枚举类型,似乎平平无奇,但使用代数数据类型能让我们更好地进行类型建模,表达更多的东西并避免运行时异常(典型如空指针)。

问题在哪里?

考虑这样的一个(简化的)场景,我们要去实现一个 TODO APP,其中每条 TODO 称为 Task,Task 有如下性质:

  1. Task 有四种状态:已完成,未完成
  2. Task 有两种种类:某日的 Task,某日期区间的 Task(即该 Task 在某日期区间内都持续进行,比如进行三天的旅行,摸鱼一个星期……)

于是,设计出数据库表后,我们编写了这样的实体(Java 语言描述):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum TaskStatus {
DONE, NOT_DONE
}
enum TaskType {
DAY, DURATION
}
class Task {
Integer id;
TaskStatus status;
TaskType type;
Date taskDate; // 对单日的 Task
Date startDate; // 日期区间 Task
Date endDate; // 对多日的 Task
Date doneDate; // 完成日期
// other fields and getter/setters
}

Java 开发时一般来说就是这么干的(枚举可能换成数据字典项啥的以节约空间,方便序列化),但这里有一个严重问题:

一些字段和其它字段是相互关联的,并且这种关联性并未在类型定义上体现出来,比如对于 startDate 和 endDate,其显然在 type 为 DURATION 的时候才有意义即非 null;对于 taskDate,其只在 type 为 DAY 时有意义;对于 doneDate,其只在 status 为 DONE 时有意义……这种关联性必须通过文档,注释等手段去表达

而且,要获取这些相关联的数据时,必须要先对 type 和 status 进行判断才能保证正确,但若程序员因不熟悉业务等情况忘记进行判断会如何?bug 可能就要出现了,而编译器并管不了这个。

Java 中的 ADT

解决方案呢?我们可以让 Task 成为一个富血对象,严格限制访问域,在 getter,setter 中去实现约束,比如下面实现一个 startDate 的 getter:

1
2
3
4
5
6
// 实际上 Optional 也是 ADT
// 或者在不合法的时候直接抛出异常,毕竟这属于编码错误了
Optional<Date> getStartDate() {
if (this.type == TaskType.DURATION) return Optional.empty();
return Optional.of(this.startDate);
}

这能强迫使用者去关心相关的约束,但仍旧显得很繁琐,遗憾的是在 Java 添加 sealed 关键字和模式匹配之前,将 ADT 应用在业务代码上确实没有多少合适的解决方案。

一个可能的解决方案是什么呢?我们可以把和 Task 的状态相关的字段和这个状态绑在一起,比如对于 TaskStatus 这个状态,我们可以为每个情况定义相应 Class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 只要所有构造器都是 private,外界便无法合法地继承 TaskStatus
abstract class TaskStatus {
private TaskStatus(){}
public static class TaskStatusDone extends TaskStatus {
Date doneDate;
}
public static class TaskStatusNotDone extends TaskStatus {}
}

class Task {
// ...
TaskStatus status;
// ...
}

“带上数据的枚举类型”!在实际操作时,对 status 就必须使用 instanceof 去判断它的类型了,判断后进行类型强转即可获取其值。

但这里仍旧有一个问题:强转是向下转型,仍旧需要使用者去确定强转的类型,且 IDE 不一定补全正确的类型,因此有一定心智负担,且仍旧可能出错。

一个简单的优化方案是在 TaskStatus 中添加相应方法去获取数据:

1
2
3
4
5
6
7
8
9
10
abstract class TaskStatus {
// 也可以在这里写抽象方法,在子类去实现,可能在子类性能会好一些?
public Optional<Date> getDoneDate() {
if (this instanceof TaskStatusDone) {
return Optional.of(((TaskStatusDone) this).doneDate);
}
return Optional.empty();
}
// ...
}

在 Kotlin,Scala,Typescript 语言中去实现的 ADT 实际上在很大程度上和这种方法(模式)是一致的,但是搭配上这些语言的模式匹配功能(以及 sealed, case 等关键字),用起来就会顺滑无比,从而真正在工程实践中有应用价值。

Typescript 中的 ADT

在 Typescript 中去实现 ADT 轻而易举,因为它本身就包含所谓的或(积 Product)类型 (|运算符),比如上面的 TaskStatus 可以直接这么表达:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 为了在运行时能够获得具体类型,必须在类型上包含特定的值即 _tag
type TaskNotDone = { _tag: 'TaskNotDone' }
type TaskDone = { _tag: 'TaskDone', doneDate: Date }
export type TaskStatus = TaskDone | TaskNotDone

const status : TaskStatus = // ...
switch (status._tag) {
case 'TaskNotDone':
// 此时 status 是 TaskNotDone(编译时可知)
break;
case 'TaskDone':
// 此时 status 是 TaskDone(编译时可知)
}

Typescript 足够聪明,用户只需要对 _tag 进行判断,它就能够知晓该数据的类型究竟是 TaskDone 还是 TaskNotDone,因此我们可以用 switch 去做简单的模式匹配(并且输入 case 的时候也能得到补全!)。

React 的 Reducer 的 Action 类型也可以看作 ADT,这时它的 type 属性就代表它的类型:

1
2
3
4
type SomeReducerAction = 
{ type: 'INC', inc: number } |
{ type: 'DEC', dec: number } |
{ type: 'SET', v: number} // ...

但在这里有一个问题——这样操作的话没法往 ADT 上面添加方法了,这对面向对象语言还是非常难受的,但仍旧可以解决,如下面的代码实现 Typescript 版的 Maybe:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
type Nothing = { _tag: 'Nothing' }
type Just<A> = { _tag: 'Just', value: A }
type Optional<A> = (Nothing | Just<A>) & OptMethods<A>

abstract class OptMethods<A> {
abstract isNothing(): this is Nothing
abstract isJust(): this is Just<A>
abstract map<B>(fn: (a: A) => B): Optional<B>
abstract orElse(orElse: A): A
abstract flatMap<B>(fn: (a: A) => Optional<B>): Optional<B>
}

// smart constructor
function mkOptional<A>(value?: A | null | undefined) : Optional<A> {
const data : Nothing | Just<A> = value ? {_tag: 'Just', value} : {_tag: 'Nothing'}
const optional : Optional<A> = {
...data,
isNothing() {
return this._tag === 'Nothing'
},
isJust() {
return this._tag === 'Just'
},
map(fn) {
if (this.isJust()) return mkOptional(fn(this.value))
else if (this.isNothing()) return mkOptional()
throw new Error('Impossible')
},
flatMap(fn) {
if (this.isNothing()) return mkOptional()
else if (this.isJust()) return fn(this.value)
throw new Error('Impossible')
},
orElse(orElse) {
if (this.isNothing()) return orElse
else if (this.isJust()) return this.value
throw new Error('Impossible')
}
}
return optional
}

export function Just<A>(value: A): Optional<A> {
return mkOptional(value)
}
export function Nothing<A>(): Optional<A> {
return mkOptional()
}

这就舒爽啦!

tips: Haskell 的 ADT 类型定义类似data Maybe a = Nothing | Just a,其中 Maybe 称为类型构造器,Nothing 和 Just 对应值构造器,Maybe 对应这里的 Optional 类型,Nothing 和 Just 对应两个同名函数

参考资料

Algebraic Data Types in TypeScript