Skip to content

Union

Type

Union is a data structure used to hold a value that can take on several different types. Only one of the types can be in use at any one time. The union can be thought of as a type having several cases, each of which can be handled in a different way when the type is manipulated.

Rationale

Unions define pure data. In contrast to protocols, they don't express business logic, nor do they carry identity without being embedded in a protocol.

Unions allow a degree of polymorphism while being type-safe.

Declaration

A union type is declared by the union keyword, followed by the name (starting with Uppercase), and the list of possible types between curly brackets {}. A union parameter, field or variable is declared like any other variable with the user-defined union type name.

The types inside a union can be any primitive type, struct, identifier, symbol, protocol, or generic type (e.g. List<Number>). Unions cannot be nested -- the following is invalid:

union First {
    Number,
    Text
}

union Second {
    Boolean,
    First // First is a union, cannot be a subtype itself
}

Initialization

A union literal starts with the type name, followed by the value initialization between parentheses, similar to a function call.

Given

union Integral {
    Number, Boolean, Text
};

then

var u = Integral(47);
u = Integral(true);

Values can be automatically converted to a union type in an assignment or a function call without explicitly mentioning their name, e.g.

function useIntegral(i: Integral) -> {
    // ....
}

function test() -> {
    var u = 47; // automatic conversion from Number to union
    useIntegral(true); // automatic conversion from Boolean to union
}

Note that the opposite direction conversion (from union to actual type) does not happen automatically.

Given

struct S1 {
    x: Number
}

union U1 {
    S1, Number
}

then the following is invalid:

function test(u: U) returns Number {
    return u.x; // unresolved reference to x
}

Match statements and expressions

To determine the type of the value that a union actually holds, a match statement or expression is used.

Matches on unions use is-expressions that always specify a concrete type. This type is then associated with an expression or statement that is evaluated when the union holds a value of that type. An else branch is executed if none of the is-branches match.

Within the associated expression, the compiler deduces the actual type of the union, and allows it to be used as such. This feature is known as "smart casting". For instance, the following is valid:

symbol eur;
struct Data { x: Number }
union Union { Data, Number, eur }

function double(x: Number) returns Number -> { return 2 * x; }

function useUnion(u: Union) returns Number -> {
    var res = 3;

    match (u) {
        is Data -> { res = u.x; }         // ok -> u is known to be S
        is Number -> { res = double(u); } // ok -> u is known to be a Number
        else -> { res = u.toNumber(); }   // ok -> u can only be the symbol chf, everything else is matched
    };

    return res;
}

Or when used as an expression:

function returnUnion(u: Union) returns Number -> {
    return match (u) {
        is Data -> u.x
        is Number -> double(u)
        else -> u.toNumber()
    };
}

Smart casting is facilitated by control flow analysis, which tracks the variable type at every position of the program. The following is valid:

function flow(u: Union) returns Number -> {
    match (u) {
        is Data -> { return u.x; } // Data is matched
    };

    return match (u) { // exhaustive, because 'u' cannot be Data at this point
        is Number -> u
        is eur -> u.toNumber()
    };
}

Union compatibility

Unions of different types are incompatible. They cannot be assigned to each other (unless smart casting is employed) and they cannot be compared.