A value that can be one of several kinds needs different fields for each kind. A command for a drawing robot is a move of some distance, a turn, a pen change, or a stop. Written as one record with every field, the record leaves the reader to remember which fields belong to which kind, and nothing stops a program from reading the distance of a turn.

A sum type states the kinds and their fields in one declaration. A value holds exactly one case, and the fields of that case are the only fields it has. The compiler lets a program read them only where it has proved the case, and it requires every place that dispatches on the value to answer every case, so a case added later names every place that has to learn it.

The example is SumTypes; it assumes records and bounds and Choices and repetition, whose case statement it extends.

An enumeration whose cases carry fields

type
    Command = (
        CommandMove(steps : int64),
        CommandTurn(right : bool, quarters : int64),
        CommandPen(down : bool),
        CommandStop
    );

This is an enumeration, written the way every enumeration is written, and the case names follow the same convention: plain names, prefixed with the type’s name so they read unambiguously wherever they stand. What is new is the parenthesis behind a case: a field set, one or more fields written like a routine’s parameters. CommandStop carries no fields, and an enumeration whose cases carry none, (North, East, South, West), is the enumeration you already know.

A value is laid out as one block: the case’s tag first, then room for the largest field set. Command is 24 bytes: the tag, and the two words of CommandTurn.

Building a value

A case name builds a value the way a call reads, with one argument per field in the order the fields are declared:

commands[0] := CommandPen(True);
commands[1] := CommandMove(3);
commands[2] := CommandTurn(True, 1);
commands[7] := CommandStop;

Each argument is checked against the field it fills, exactly as an assignment to that field would be, and a case with fields cannot be built without them:

the case 'CommandMove' carries a field set, so a value of it is built with its fields: 'CommandMove(...)'

Reading a case: arms that bind

A value’s fields are not reachable by name. command.steps does not exist, because on most commands there are no steps to read. The fields are read in a case arm that names the case and binds its field set:

case command of
    CommandMove(m):
        if robot.penDown then
            robot.drawn := robot.drawn + m.steps;
    CommandTurn(t): robot.heading := Turned(robot.heading, t.right, t.quarters);
    CommandPen(p):  robot.penDown := p.down;
    CommandStop:    WriteLn("  stop at (%lld, %lld)", robot.x, robot.y)
end;

m names the whole field set of CommandMove, and its fields are read by name: m.steps. The binding exists only inside its arm. It is a read-only view of the value’s own storage, not a copy, so reading a large field set costs nothing.

An arm that needs to know the case but not its fields writes the wildcard CommandTurn(*), and several cases share one arm when none of them binds:

case robot.last of
    CommandStop: WriteLn("  back home after the stop");
    CommandMove(*), CommandTurn(*), CommandPen(*): WriteLn("  still running")
end;

A binding the arm never reads is reported as a warning that suggests the wildcard.

Every case has its arm

A case over a sum value follows the rule every case follows: the arms cover every case, or an else says what happens to the rest. Leave CommandPen out of the dispatch above and the build stops:

the case over 'Command' covers 3 of the 4 values of its domain and has no else: a case must say what happens to every value — cover the domain or add an else

Writing no else is what makes this useful. When CommandWait(ticks : int64) joins the type next year, every dispatch without an else stops the build at its own line, and none of them silently does nothing.

Testing one case with is

value is Case is a boolean, true when the value holds that case:

if commands[i] is CommandMove(*) then
    moves := moves + 1;

When the test is the whole condition of an if, it may bind the case’s fields for the then statement, which is the arm the binding lives in:

if robot.last is CommandMove(m) then
    WriteLn("A fresh robot's last command is a move of %lld steps", m.steps);

Anywhere else a binding has no arm to live in, and the compiler says so:

the test binds 'm', which needs an arm to live in: a test that binds must be the whole condition of an 'if', whose then statement reads the binding — test without a binding here, or write 'is Case(*)'

A record with fields that depend on the case

A robot has fields every robot has, a position, a heading, whether the pen is down, and one field whose shape depends on the command it last ran. That is a record with a sum-typed field:

Robot = record
    x, y : int64;
    heading : Heading;
    penDown : bool;
    drawn : int64;
    last : Command;
end;

The common fields are ordinary fields. The fields that depend on the case are reachable only through an arm over robot.last, and changing the case means assigning a whole new value, robot.last := CommandStop, so no read can ever see one case’s bytes as another case’s fields.

What an arm holds still

A binding reads the matched value’s own storage, so the arm must not change that value while the binding exists. Inside an arm, the binding cannot be written, and neither can the matched place or anything that contains it. The fields beside it stay free:

case robot.last of
    CommandMove(m):
    begin
        robot.x := m.steps;             { a sibling field: allowed }
        robot.last := CommandStop;      { the matched field: refused }
    end;
    CommandTurn(*), CommandPen(*), CommandStop: robot.x := 0
end;
'robot.last' holds the case whose fields this arm reads through 'm', so it cannot be changed inside the arm: change it after the arm

A call can change a value the arm never names: a procedure that assigns the robot, which is a unit variable, or one handed a pointer that reaches it. The compiler follows every call inside an arm through everything the call does, and refuses one that could change the matched value. With procedure Forget(); begin robot.last := CommandStop end; called in the arm above:

'Forget' may change 'robot.last' while this arm reads it through 'm': call it after the arm, or copy the fields the call needs into variables and match again afterwards

When the matched value is not a variable or a field of one, a call result or an array element, it is read once into a hidden copy before the arms run, so the arm may change the array or the index freely, and the call runs exactly once.

Values like every other value

A sum value is a value. Assignment copies it, a routine takes it by value or as a const parameter and returns it as a result, and it is an element of an array or a field of a record like any other type. A variable that was never assigned holds the first case with every field at its default, the same rule that makes an enumeration’s first literal its zero value. A sum value crosses into a scheduled task as an argument and travels as a stream element, the text and the arrays of the case it holds copied with it.

A field set may hold a string:

type
    Note = (NoteText(text : string), NoteCount(count : int64), NoteEmpty);

A copy of a Note copies the text of the case it holds and nothing else, so the copy owns its text, and a later store into the original never shows through the copy. A sum type with a string in a field set is not plain, so, like a record holding a string, it does not cross into a scheduled task as an argument.

A case can hold a dynamic array as well, and the copy works the same way: it copies the array of the case the value holds, and a case the store replaces gives its array back.

program Shapes;

imp
    WriteLn, Append, Length : std;

type
    Points = array of float64;
    Shape = (ShapeEmpty, ShapePolyline(xs : Points, name : string));

var
    xs : Points;
    first, second : Shape;

begin
    Append(address xs, 1.5);
    Append(address xs, 2.5);
    first := ShapePolyline(xs, "path");
    second := first;
    first := ShapeEmpty;

    case second of
        ShapeEmpty:       WriteLn("empty");
        ShapePolyline(p): WriteLn("%ls has %lld points", p.name, Length(p.xs))
    end;
end.

Where the boundary is today

The field sets of a sum type hold plain values, strings and dynamic arrays of those: numbers, booleans, enumerations, strings, fixed arrays, records of those, other sum types over them, and array of any of these that holds no array of its own. A pointer is refused for now, and so is an array of arrays and a sum type that contains itself through array of; they belong to the next step, which brings trees such as a JSON value. A generic sum type and a sum type exported from a library are also not available yet. Known limitations states each boundary with the spelling that works today.