rokdocs
v0.1

5. Sum Types and none

When a value is "either this or that", rok joins the types with |, as in A | B. This chapter covers sum types and the tools for working with the most common one, T | none, which means "there may be no value".

Sum types A | B

A value of a sum type belongs to exactly one of the listed variants. The value carries a tag recording which one, so match can extract it with a type-test pattern name: Type.

fn describe(v: i32 | str | bool) -> str {
    match v {
        n: i32 => f"integer {n}",
        s: str => f"string {s}",
        b: bool => f"boolean {b}",
    }
}

println(describe(42));
println(describe("rok"));
println(describe(true));
integer 42
string rok
boolean true

Sum types are closed. The compiler knows every possible variant, so removing the bool arm from the match above is a compile error saying not all cases are handled. When you add a new variant, the compiler points you to every place that needs updating.

Sum types behave like sets. i32 | str and str | i32 are the same type, and i32 | i32 is just i32. Long sums can be named with type.

type Number = i32 | f64;

fn half(n: Number) -> f64 {
    match n {
        i: i32 => i as f64 / 2.0,
        f: f64 => f / 2.0,
    }
}

println(f"{half(7)} {half(1.5)}");
3.5 0.75

none and T?

none is the literal for "no value". Where other languages use Option<T> or null, rok uses the sum type T | none, written T? for short. There is no Some(..) wrapper, so you return the value as is.

fn find(xs: List<i32>, target: i32) -> usize? {
    for i in 0..xs.len() {
        if xs[i] == target { return i; }
    }
    none
}

let xs = [3, 1, 4];
println(f"{find(xs, 4):?}");
println(f"{find(xs, 7):?}");
2
none

The standard library follows the same convention. Lookups that can fail, such as List::get, Dict::get, and List::pop, all return T | none.

Note

Advanced: T | none is nearly free. For types with unused bit patterns, such as bool, pointers, and field-less enums, none is stored in one of those patterns with no separate tag (niche optimization). That is why bool? is 1 byte.

Narrowing with is

x is T is a bool expression that tests whether the value is currently the T variant. When an if condition tests a local variable, the variable is narrowed to T inside that branch, so you can use T's operations directly. The same holds for later conditions joined with &&.

let v: i32 | str = 7;
if v is i32 && v > 5 {
    println(f"big number: {v + 1}");
}

let w: i32 | str = "hello";
if w is str {
    println(f"length: {w.len()}");
}
println(f"{w is i32}");
big number: 8
length: 5
false

Inside the narrowed branch you can still assign to the variable. A value of the narrowed type keeps the narrowing; a value of another member of the declared type ends it, and from there to the end of the branch the variable has its declared type again.

let v: i32 | str = 5;
if v is i32 {
    v = v + 1;              // still i32
    println(f"{v * 2}");
    v = "six";              // ends the narrowing: v is i32 | str again
    println(f"{v is str} {v:?}");
}
12
true "six"

After v = "six", writing v + 1 would be a type error, because + is not defined on i32 | str.

if let and match

To test and bind the value to a new name at the same time, use if let name: T = expr. In match, write none as a literal pattern.

fn lookup(id: i32) -> str? {
    if id == 1 { "kim" } else { none }
}

if let name: str = lookup(1) {
    println(f"found: {name}");
} else {
    println("not found");
}

for id in [1, 2] {
    match lookup(id) {
        name: str => println(f"{id} → {name}"),
        none => println(f"{id} → (none)"),
    }
}
found: kim
1 → kim
2 → (none)

let else: bail out early

let pattern = expr else { ... }; binds the name if the pattern matches and otherwise runs the else block, which must leave the scope with return, break, continue, or throw. This lets you write "bail out early if missing" without extra indentation, and after it the name has the narrowed type.

fn first_even(xs: List<i32>) -> i32 | none {
    for x in xs {
        if x % 2 == 0 { return x; }
    }
    none
}

fn report(xs: List<i32>) -> str {
    let n: i32 = first_even(xs) else {
        return "no even number";
    };
    f"first even is {n}, half is {n / 2}"
}

println(report([1, 3, 8, 10]));
println(report([1, 3]));
first even is 8, half is 4
no even number

Defaults with ?? and chaining with ?.

Writing match every time gets tedious. a ?? b returns b if a is none, and a's value otherwise. x?.field and x?.method() evaluate to none when x is none, and only perform the access when there is a value. Combined, "take it if present, otherwise use a default" fits on one line.

struct User { name: str; nick: str?; }

let u: User? = User { name: "kim", nick: none };
let nobody: User? = none;

println(u?.name ?? "anonymous");
println(nobody?.name ?? "anonymous");
println(f"{u?.name?.len():?}");

let scores: Dict<str, i32> = {"kim": 90};
println(f"{scores.get("kim") ?? 0} {scores.get("lee") ?? 0}");
kim
anonymous
3
90 0

?. short-circuits. If the left side is none, the method after it is never called.

Postfix ?: return early on none

When a function has to unwrap several T | none values in sequence, use postfix ?. e? extracts the value of e, and if it is none, immediately returns none from the enclosing function. The function's return type must therefore include none.

struct User { name: str; nick: str?; }

fn nick_len(u: User?) -> usize? {
    let user = u?;
    let nick = user.nick?;
    nick.len()
}

println(f"{nick_len(User { name: "a", nick: "bob" }):?}");
println(f"{nick_len(User { name: "a", nick: none }):?}");
println(f"{nick_len(none):?}");
3
none
none

? works only on T | none. It is not a way to propagate errors; errors are handled with throws / try (error control flow).

Note

Advanced: if a collection stores none itself (List<i32?>), the result of get is a two-level optional (i32 | none)? that distinguishes "no element" from "a stored none". ??, ?., and ? act only on the outer level. See nested optionals for the full rules.

Summary

  • A | B is a closed sum type. Variant order and duplicates don't matter, and match must handle every variant.
  • When a value may be absent, use T | none, or T? for short. Return the value as is, without wrapping.
  • x is T both tests and narrows. Extract values with if let x: T = ..., let x: T = ... else { return .. };, and the n: T / none patterns in match.
  • ?? supplies a default, ?. skips the access on none, and postfix ? returns from the function on none.

Further reading

Edit this page on GitHub