Variants & switch

Implemented

Sum types, constructors, exhaustive switch, generics and recursive types.

A variant is a named sum type. Like records and abilities it is structural: its identity is a function of its constructors’ shapes, not a generated id. switch takes one apart and the checker insists every case is covered.

type shape = | Circle(int) | Rect(int, int)

let area = (s: shape): int => switch s {
| Circle(r) => r * r * 3
| Rect(w, h) => w * h
}

let total = area(Rect(2, 5))
Verified through UCM: total is 10.
  • Constructors are written with parentheses when they are built, even when nullary (None()); a nullary constructor in a pattern is just its name (| Empty =>).
  • Type parameters work (one to N), as do self-recursive types (type tree = | Leaf | Node(tree, int, tree)) and mutually recursive ones.
  • A record, variant or generic can be named in a parameter, return or let annotation.

Taking values apart

A switch arm is | pattern => body. A pattern can be a constructor, a literal, a variable, a list or tuple pattern, or _ (an ordinary identifier the compiler treats as a wildcard), and they nest. Two additions make arms more precise without nesting more switches: a guard (if condition after the pattern) and an as-pattern (pattern as name, which also binds the whole matched value). The example below uses both, and also passes a constructor as a function:

type shape = | Circle(int) | Rect(int, int) | Empty

let area = (s: shape): int => switch s {
| Circle(r) => r * r * 3
| Rect(w, h) => w * h
| Empty => 0
}

let describe = (s: shape): string => switch s {
| Circle(r) if r > 100 => "huge circle"
| Circle(_) => "circle"
| Rect(w, h) as whole if w == h => "square of area " ++ Int.toText(area(whole))
| Rect(_, _) => "rectangle"
| Empty => "nothing"
}

let circles = List.map([1, 2, 3], Circle)
Verified through UCM. Arms are tried top to bottom, so the guarded Circle arm must come before the general one.

Read describe from the top. The first arm only matches a circle whose radius passes the guard; when the guard is false the next arm is tried, so a plain Circle(_) below it catches the rest. The Rect arm does two things at once: Rect(w, h) names the parts for the guard to compare, and as whole keeps the entire Rect so area can be called on it without rebuilding it. A guard is part of the arm’s identity (Unison hashes it between the pattern and the body), and a guarded arm does not count toward exhaustiveness: the checker still wants an unguarded arm for every case.

The last line shows that a bare constructor name is a function value: Circle has type int -> shape and can be handed to List.map. It is a real constructor term rather than a lambda, so it hashes differently from (r) => Circle(r), just as it does in Unison. A nullary constructor must still be written None() when used as a value.

Literal patterns

Literal patterns work on int, nat, string, bool and bytes. A negative int literal is a single pattern and a nat takes the n suffix:

let compareWord = (a: string, b: string): string => switch Universal.compare(a, b) {
| -1 => "before"
| 0 => "same"
| _ => "after"
}

let countName = (n: nat): string => switch n {
| 0n => "none"
| 1n => "one"
| _ => "many"
}
Verified through UCM. Universal.compare gives -1, 0 or 1.

-1 is parsed as one literal (not as a subtraction), and a pattern below the smallest int is refused.

Several types of the same shape

Types are structural: two types with the same shape hash to one declaration, whatever they are called. Clay keeps the names you gave each one, so both print back the way you wrote them, and a term built with either has one hash. When you want two types that must never be confused, make them nominal with unique:

unique type userId = {value: nat}
unique type orderId = {value: nat}

type point = {x: int, y: int}
type vec = {x: int, y: int}

let here: point = {x: 0, y: 0}
let heading: vec = {x: 1, y: 0}
let sameX = here.x == heading.x
Verified through UCM. userId and orderId are different types; point and vec are one structural shape with two names.

unique type mints a fresh identifier the first time the type is added and then keeps it; write unique["guid"] type ... to fix the identifier yourself (this is what you want when a type must hash the same on two machines). Without unique, a record literal whose type cannot be settled from its fields alone needs an annotation, as here and heading have above, because both point and vec fit.

Renaming a constructor is not supported yet (renaming the type’s own name is).