Enums, options, and match
Memory: copying an enum copies its whole payload.
OptionandResultwork the same way — no null, no exceptions. User enums whose variants carry only scalar payloads are stored inline; recursive, generic and heap-bearing enums are boxed.
A *sum type* is a value that is exactly one of several alternatives. Tycho has one general form, the user-defined enum, plus two built-in special cases for two common problems: Option(T) (a value that might be absent, without null) and Result(T, E) (failure, without exceptions). You take all three apart the same way — an exhaustive match — and all three are ordinary values, copied on assignment and compared structurally with ==, like everything else in the language.
enum — user-defined sum types
An enum declares a type whose value is exactly one of several named variants, each carrying a payload of zero or more values. It is a tagged union: each value carries a tag naming the variant it holds:
enum Shape:
Circle(float) # a variant with a payload
Rect(float, float)
Unit # a payload-less variant
fn area(s: Shape) -> float:
match s: # exhaustive: every variant needs an arm
Circle(r):
return 3.14159 * r * r
Rect(w, h):
return w * h
Unit:
return 1.0
You build a value by naming a variant — Circle(2.0), or a bare Unit for a payload-less one — and take it apart with match (below). Variant names are global: you write Circle, not Shape.Circle, and no two enums may share a variant name.
An enum value is an ordinary value: copying one copies the whole payload, and == compares two values structurally. An enum may appear anywhere a type may — a struct field, an array element ([Shape]), a map value, a function parameter or return.
Recursive enums are ASTs
A variant may carry the enum itself, which makes the type a tree — the shape compilers call an abstract syntax tree. Each level holds the rest of the tree, so a recursive enum still names a finite value, and copying one copies the entire tree:
enum Expr:
Num(float)
Add(Expr, Expr) # recursive: a variant carrying Expr
Neg(Expr)
fn eval(e: Expr) -> float:
match e:
Num(v):
return v
Add(l, r):
return eval(l) + eval(r)
Neg(x):
return -eval(x)
fn main():
e := Add(Num(2.0), Neg(Num(4.0)))
print(str(eval(e))) # -2.0
An enum may also be generic — enum Tree($T) — and is then stamped out per concrete type like a generic struct, including recursive payloads that name the enum itself. See Generics.
match — the exhaustive eliminator
match is how every sum type is consumed. It dispatches on the variant and binds the payload in one step:
enum Shape:
Circle(float)
Rect(int, int)
Unit
shape := Circle(1.5)
match shape:
Circle(r): # r is bound to the payload (a float here)
println(str(r))
Rect(w, h): # multiple payload fields bind in order
println(str(w * h))
Unit: # no payload, no bindings
println("unit")
Two rules keep match safe:
- It is exhaustive. Every variant of the matched type must have an arm; a missing case is a compile error, so adding a variant forces you to handle it everywhere. A wildcard arm
_:matches any remaining variant when you really want a catch-all. - Bindings are local to their arm.
rexists only inside theCirclearm. There is no way to read a payload that does not belong to the current variant.
match is a statement. To produce a value, assign or return from inside each arm, as the eval example does — or use the expression form below.
if / match as an expression (tail position)
An if or match may stand as the whole right-hand side of a :=, a typed x : T =, a plain x = / place assignment, or a return. The value of the taken branch/arm becomes the value bound, assigned, or returned:
enum Status:
Active
Idle
Done
status := Active
_label := match status: # inferred type (all arms must agree)
Active:
"on"
Idle:
"waiting"
Done:
"finished"
a := 3
b := 7
_max := if a > b: # `elif` chains are allowed; an `else` is required
a
else:
b
fn parse(t: string) -> Result(int, string):
return Ok(len(t))
s := "42"
_x := match parse(s): # each arm's value is the result
Ok(v):
v
Err(_):
0
Rules: each branch/arm is a single expression (its value); a value if must have an else (every path must produce a value); a value match is exhaustive as always; and all branches must have the same type, which becomes the type of the whole expression. This is shorthand for declaring a variable and assigning it in each arm.
Not yet supported: multi-statement branches, a branch that returns instead of yielding a value, and use as a nested sub-expression (e.g. 1 + if …). Use the statement form for those.
Option(T) — a value, or nothing
Option(T) is the built-in enum with variants Some(x) and None. It replaces the null reference with a typed absence: a value that may be missing is *typed* as possibly missing, and you cannot ignore the absence, because reading it requires an exhaustive match.
fn index_of(xs: [int], target: int) -> Option(int):
for i := 0; i < len(xs); i += 1:
if xs[i] == target:
return Some(i)
return None # None's type comes from the return type
fn main():
match index_of([10, 20, 30], 20):
Some(i): # i is bound to the value (an int here)
println("found at " + str(i))
None:
println("not found")
None has no type of its own, so it is only allowed where the expected type is already known — a return type, a declaration annotation (box : Option(string) = None), an assignment target, or a call argument. A bare x := None is a compile error, because there is nothing to fix T.
T may be any type, including another option (Option(Option(int))); each instantiation is a distinct tagged value, copied like any other. An option is a fine struct field (age: Option(int)) or array element ([Option(int)] — a list of optionals, where a None element takes its type from the others, so the first element cannot be a bare None).
Two options compare with == (structurally — Some(1) == Some(1) is true), and so do values that merely contain them. A bare None has no type for == to use as an expected type, so Some(1) == None and opt == None are rejected; bind or annotate the absent value first, or use opt is None when testing its variant.
Result(T, E) — success, or an error
Result(T, E) is the built-in enum with variants Ok(value) and Err(error) — error handling without exceptions. A function that can fail returns one, and the caller handles both outcomes in a match:
fn checked_div(a: int, b: int) -> Result(int, string):
if b == 0:
return Err("divide by zero")
return Ok(a / b)
fn main():
match checked_div(10, 0):
Ok(v):
println("= " + str(v))
Err(e):
println("error: " + e)
Like None, a bare Ok(v) or Err(e) fixes only one of the two type parameters, so the other comes from context (a return type, an annotation, an assignment target, or an argument); a bare x := Ok(1) is a compile error. Both T and E may be any type, including heap ones such as Result([int], string). Two Result values compare with ==, recursing through T and E. The payload may be void: Ok() is a value and Ok(): is its match arm, for procedures that report only success or failure.
or_return — propagating errors without boilerplate
Chaining fallible calls with match at every step gets noisy. or_return cuts it: writing v := expr or_return unwraps an Ok (binding v to the value) or, the moment expr is an Err, returns that Err from the enclosing function — which must itself return a Result(_, E) with the *same* error type E:
fn parse_digit(s: string) -> Result(int, string):
if len(s) == 1 and s >= "0" and s <= "9":
return Ok(to_int(to_float(s[0]) - to_float("0"[0])))
return Err("not a digit: " + s)
fn add_two(a: string, b: string) -> Result(int, string):
x := parse_digit(a) or_return # Ok -> bind x; Err -> return it from add_two
y := parse_digit(b) or_return
return Ok(x + y)
or_return comes after the expression it applies to and binds tighter than any arithmetic, so it is valid anywhere the unwrapped value is wanted — foo(parse(s) or_return), return Ok(parse(s) or_return + 1), and inside nested blocks. The short-circuited Err lives as long as the caller needs it.
The same operator works on Option: inside a function that returns Option(T), v := opt or_return binds v on Some(v) and returns None on None.
or_else — returning something else on failure
or_return hands back the error it was given. When the caller should see a *different* value — your own error type wrapping a library's, or false/None from a function that does not return a Result at all — use or_else: v := e or_else x: h binds v on Ok(v)/Some(v); on Err(err) it binds err to x and returns h from the enclosing function. h must have that function's return type. Write _ to bind nothing, and on an Option you must, since None carries no value.
enum ConfErr:
BadPort(string)
fn port_of(s: string) -> Result(int, ConfErr):
n := parse_digit(s) or_else e: Err(BadPort(e)) # wrap the callee's error
return Ok(8000 + n)
fn is_digit(s: string) -> bool:
n := parse_digit(s) or_else _: false # a bool fn gives up with false
return n >= 0
fn parse_digit(s: string) -> Result(int, string):
if len(s) == 1 and s >= "0" and s <= "9":
return Ok(to_int(to_float(s[0]) - to_float("0"[0])))
return Err("not a digit: " + s)
fn main():
match port_of("7"):
Ok(p): println(str(p))
Err(BadPort(m)): println(m)
println(str(is_digit("x")))
h runs to the end of the line (or the closing bracket), so it is one expression, never a block. The full rules are §14.6.1.
How this is implemented — payload layout, why recursive enums stay finite, how the value form and the error operators lower — is in design-enums, for maintainers.