TYCHO Search

Search documentation

    ↑ ↓ to move · Enter to open · Esc to close

    Enums, options, and match

    Memory: copying an enum copies its whole payload. Option and Result work 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:

    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.