Skip to main content
Tolk supports union types such as T1 | T2 | T3. A union type represents a value that can be one of several types. Pattern matching is used to distinguish union variants. A special case T | null is written as T? and referred to as a nullable type.
struct (0x12345678) Increment { /* ... */ }
struct (0x23456789) Reset { /* ... */ }

type IncomingMsg = Increment | Reset

fun handle(m: IncomingMsg) {
    match (m) {
        Increment => { /* here m is `Increment` */ }
        Reset =>     { /* here m is `Reset` */     }
    }
}

Arbitrary union types

Union types are not limited to structures. Any types can be combined into a union. The following union types are valid:
  • int | slice;
  • address | Point | null;
  • Increment | Reset | coins;
  • int8 | int16 | int32 | int64.
Union types are automatically flattened:
type Int8Or16 = int8 | int16

struct Demo {
    t1: Int8Or16 | int32?   // int8 | int16 | int32 | null
    t2: int | int           // int
}
Union types support assignment based on subtype relations. For example, a value of type B | C can be passed to or assigned to A | B | C | D:
fun take(v: bits2 | bits4 | bits8 | bits16) {}

fun demo() {
    take(someSlice as bits4);    // ok
    take(anotherV);              // ok for `bits2 | bits16`
}

Exhaustive pattern matching

A match expression must cover all possible cases. It must be exhaustive.
fun errDemo(v: int | slice | Point) {
    match (v) {
        slice => { v.loadAddress() }
        int => { v * 2 }
        // error: missing `Point`
    }
}
match can be used with nullable types, since T? is equivalent to T | null. It can also be used as an expression:
fun replaceNullWith0(maybeInt: int?): int {
    return match (maybeInt) {
        null => 0,
        int => maybeInt,
    }
}

Union type inference errors

Auto-inference of a union type results in an error. If match arms produce values of different types, the inferred result is a union, which is typically not intended:
var a = match (...) {
    ... => beginCell(),
    ... => 123,
};
The type of a is inferred as builder | int. In most cases, this indicates an error in the code. In such cases, the compiler emits the following message:
error: type of `match` was inferred as `builder | int`; probably, it's not what you expected
assign it to a variable `var a: <type> = match (...) { ... }` manually
To resolve this, either explicitly declare a as a union type or fix the code if the union is unintended. The same rule applies to other cases, such as return type inference:
fun f() {
    if (...) { return someInt64 }
    else { return someInt32 }
}
The result is inferred as int32 | int64. While it is valid, a single integer type is usually expected. The compiler reports an error. To fix it, explicitly declare the return type:
fun f(): int {
    if (...) { return someInt64 }
    else { return someInt32 }
}
Declaring return types is recommended practice.

is and !is operators

Union types can be tested using the is operator:
fun demo(v: A | B) {
    if (v is A) {
        v.aMethod();
    } else {
        v.bMethod();
    }
}

lazy matching for unions

In message-handling, union values are commonly parsed using lazy:
fun onInternalMessage(in: InMessage) {
    val msg = lazy MyUnion.fromSlice(in.body);
    match (msg) {
        // ...
    }
}
This pattern is referred to as lazy match:
  1. No union is allocated on the stack upfront; loading is deferred until needed.
  2. match operates by inspecting the slice prefix (opcode), instead of checking a type identifier on the stack.
Union types continue to work correctly without lazy and follow the same type-system rules.

Stack layout and serialization

Union types have a complex stack layout, commonly referred to as tagged unions. Serialization depends on whether the union consists of structures with manual serialization prefixes:
  • if yes; for example, struct (0x1234) A, those prefixes are used;
  • if no, the compiler automatically generates a prefix tree; for example, T1 | T2 is called Either type: 0 + T1 or 1 + T2.