From 0983524ae6b5b12eac90a832ddd95b5b75344ecf Mon Sep 17 00:00:00 2001 From: sfja Date: Wed, 11 Mar 2026 11:45:01 +0100 Subject: [PATCH] add docs --- docs/tagged_union_pattern_in_typescript.md | 225 +++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 docs/tagged_union_pattern_in_typescript.md diff --git a/docs/tagged_union_pattern_in_typescript.md b/docs/tagged_union_pattern_in_typescript.md new file mode 100644 index 0000000..9b1b715 --- /dev/null +++ b/docs/tagged_union_pattern_in_typescript.md @@ -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 = 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: Tag): this is ExprWithKind { + return this.kind.tag === tag; + } + // ... +} +``` + +The type predicate tells Typescript that it can expect the `Expr` to conform to `ExprWithKind` 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: Tag): asserts this is ExprWithKind { + if (!this.is(tag)) { + throw new Error(); + } + } + + as(tag: Tag): ExprWithKind { + 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 = Omit, "tag">; + +class Expr { + // ... + static create( + tag: Tag, + kind: ExprKindData, + line: number, + ): ExprWithKind { + 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); + } + } +}); +``` +