add docs
This commit is contained in:
parent
cad88a6729
commit
0983524ae6
225
docs/tagged_union_pattern_in_typescript.md
Normal file
225
docs/tagged_union_pattern_in_typescript.md
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
|
||||||
|
# Tagged union pattern in Typescript
|
||||||
|
|
||||||
|
To show the pattern, let's implement a simple interpreter.
|
||||||
|
|
||||||
|
Start by defining each union variant, or *kind*, of the type:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ExprKind =
|
||||||
|
| { tag: "Int", value: number }
|
||||||
|
| { tag: "Add", left: Expr, right: Expr }
|
||||||
|
```
|
||||||
|
|
||||||
|
Then define the type itself as a class:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class Expr {
|
||||||
|
constructor(
|
||||||
|
public line: number,
|
||||||
|
public kind: ExprKind,
|
||||||
|
) {}
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each instance of `Expr` will have a `line` (source line number), but only *IntExpr* will have `value`, and only *AddExpr* will have `left` and `right`.
|
||||||
|
|
||||||
|
To *match* each variant, a type representing an `Expr` with a specific variant:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ExprTag = ExprKind["tag"];
|
||||||
|
|
||||||
|
type ExprWithKind<Tag extends ExprTag> = Expr & { kind: { tag: Tag } };
|
||||||
|
```
|
||||||
|
|
||||||
|
The type `ExprKind["Tag"]` is a union of each `tag` value, i.e. in this example `"Int" | "Add"`. Saying `Tag extends ExprTag` means that `Tag` (type) is either `"Int"` or `"Add"`, i.e. for `.is("Int")`, `Tag` will be `"Int"` and for `.is("Add")`, `Tag` will be `"Add"`. The *narrowed* type `ExprWithKind` is defined by saying that we want an `Expr` that specifically has the `kind.tag` value of `Tag`.
|
||||||
|
|
||||||
|
Then define a methed that with a *type predicate*:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class Expr {
|
||||||
|
// ...
|
||||||
|
is<Tag extends ExprTag>(tag: Tag): this is ExprWithKind<Tag> {
|
||||||
|
return this.kind.tag === tag;
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The type predicate tells Typescript that it can expect the `Expr` to conform to `ExprWithKind<Tag>` if `.is` evaluates to `true`. Using this, access can be gained to the variant fields in a type safe manner.
|
||||||
|
|
||||||
|
Now, it's possible to match each variant:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function eval(expr: Expr): number {
|
||||||
|
|
||||||
|
if (expr.is("Int")) {
|
||||||
|
return expr.kind.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expr.is("Add")) {
|
||||||
|
return eval(expr.kind.left) + eval(expr.kind.right);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`'${expr.kind.tag}' not handled`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
By having `Expr` be a class, we can also define functions like this as methods:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class Expr {
|
||||||
|
// ...
|
||||||
|
eval(): number {
|
||||||
|
if (expr.is("Int"))
|
||||||
|
return expr.kind.value;
|
||||||
|
if (expr.is("Add"))
|
||||||
|
return expr.kind.left.eval() + expr.kind.right.eval();
|
||||||
|
throw new Error(`'${expr.kind.tag}' not handled`);
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
By having the `tag` invariant, the Typescript compiler can check that you exhaust each variant in a match. To match each variant, I use this pattern ('k-pattern'):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class Expr {
|
||||||
|
// ...
|
||||||
|
eval(): number {
|
||||||
|
const k = this.kind;
|
||||||
|
switch (k.tag) { // Typescript language servers can auto-complete all variants
|
||||||
|
case "Int":
|
||||||
|
return k.value;
|
||||||
|
case "Add":
|
||||||
|
return k.right.eval() + k.left.eval();
|
||||||
|
}
|
||||||
|
const _: never = k; // compile time exhaustiveness check
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sometimes you need to narrow to a variant that you already know is the correct variant. For that purpose, define these helper functions:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class Expr {
|
||||||
|
// ...
|
||||||
|
assertIs<Tag extends ExprTag>(tag: Tag): asserts this is ExprWithKind<Tag> {
|
||||||
|
if (!this.is(tag)) {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
as<Tag extends ExprTag>(tag: Tag): ExprWithKind<Tag> {
|
||||||
|
this.assertIs(tag);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Using these, narrowing can be done more conveniently:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// this will always be an IntExpr
|
||||||
|
let expr: Expr = ...;
|
||||||
|
|
||||||
|
expr.kind.value; // Property 'value' does not exist ...
|
||||||
|
|
||||||
|
expr.as("Int").kind.value; // ok
|
||||||
|
|
||||||
|
expr.assertIs("Int"); // will throw if expr isn't an IntExpr
|
||||||
|
expr.kind.value; // ok
|
||||||
|
```
|
||||||
|
|
||||||
|
For some applications, it might be more conveniently with a factory-function like this:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ExprKindData<Tag extends ExprTag> = Omit<ExprWithKind<Tag>, "tag">;
|
||||||
|
|
||||||
|
class Expr {
|
||||||
|
// ...
|
||||||
|
static create<Tag extends ExprTag>(
|
||||||
|
tag: Tag,
|
||||||
|
kind: ExprKindData<Tag>,
|
||||||
|
line: number,
|
||||||
|
): ExprWithKind<Tag> {
|
||||||
|
return new Expr(line, {...kind, tag} as ExprKind & { tag: Ty });
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And use it like this:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const myInt = Expr.create("Int", { value: 123 }, line);
|
||||||
|
```
|
||||||
|
|
||||||
|
For often-used variants, it might be more convenient with a specific type and constructor:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type IntExpr = ExprWithKind<"Int">;
|
||||||
|
const IntExpr = (line: number, value: number): IntExpr =>
|
||||||
|
Expr.create("Int", { value }, line);
|
||||||
|
```
|
||||||
|
|
||||||
|
That can be used like this:
|
||||||
|
```ts
|
||||||
|
function exprIdentity(expr: Expr): Expr { return expr; }
|
||||||
|
function intExprIdentity(expr: IntExpr): IntExpr { return expr; }
|
||||||
|
|
||||||
|
const myIntExpr = IntExpr(123, line);
|
||||||
|
myIntExpr.kind.value;
|
||||||
|
exprIdentity(myIntExpr);
|
||||||
|
intExprIdentity(myIntExpr);
|
||||||
|
|
||||||
|
const myOtherExpr = Expr.create("Add", ...);
|
||||||
|
exprIdentity(myIntExpr);
|
||||||
|
intExprIdentity(myIntExpr); // error
|
||||||
|
```
|
||||||
|
|
||||||
|
Using this pattern, it's easy to implement a visitor pattern:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface ExprVisitor {
|
||||||
|
visit(expr: Expr): void
|
||||||
|
}
|
||||||
|
|
||||||
|
class Expr {
|
||||||
|
// ...
|
||||||
|
visit(v: ExprVisitor) {
|
||||||
|
v.visit(this);
|
||||||
|
const k = this.kind;
|
||||||
|
switch (k.tag) {
|
||||||
|
case "Int":
|
||||||
|
return;
|
||||||
|
case "Add":
|
||||||
|
k.left.visit(v);
|
||||||
|
k.right.visit(v);
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const _: never = k;
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The visitor interface can be used like:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const expr: Expr = parse("2 + (3 + 4)");
|
||||||
|
|
||||||
|
const intValues: number[] = [];
|
||||||
|
|
||||||
|
expr.visit({
|
||||||
|
visit(expr) {
|
||||||
|
if (expr.is("Int")) {
|
||||||
|
intValues.push(expr.kind.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user