4.14. Sum types
A record holds all of its fields at once. A sum type holds one shape out of
several. You write one with data, listing every constructor the type has:
data Color = Red | Green | Blue
Color now has exactly three values. Red, Green and Blue are ordinary
terms that you can return, pass, put in a list, or match on.
A constructor belongs to one type and no other, so the compiler works out the
type from the constructor alone. That is why warmest below needs no
signature:
module main (describe, palette, warmest)
import root-py
data Color = Red | Green | Blue
describe :: Color -> Str
describe | Red = "warm"
| Green = "cool"
| Blue = "cold"
palette :: [Color]
palette = [Red, Green, Blue]
warmest = Red
$ morloc typecheck colors.loc
describe :: Color -> Str
palette :: [Color]
warmest :: Color
describe takes the value apart with |-clauses (see Pattern Matching).
A constructor in a clause is a test, not a binding: Red matches the value
Red and nothing else.
$ morloc make -o colors colors.loc
$ ./colors describe Green
"cool"
$ ./colors palette
["Red","Green","Blue"]
4.14.1. A constructor set is closed, and the compiler counts
A |-match over a data type does not need a catch-all, because the compiler
knows how many constructors there are. It also does not let you forget one.
Drop the Blue clause from describe and the build stops:
colors.loc:8:1: `|` patterns for 'describe' are not exhaustive; missing Blue
|
8 | describe | Red = "warm"
| ^
The same knowledge runs in the other direction. Add a second Red clause and
it can never fire, so it is rejected rather than silently dropped:
colors.loc:8:1: `|` patterns for 'describe' match 'Red' more than once; the later clause is unreachable
|
8 | describe | Red = "warm"
| ^
A catch-all is still allowed when you want one:
warm :: Color -> Bool
warm | Red = True
| _ = False
4.14.2. The constructor names reach the interface
Constructor names are part of the type, so every interface Morloc derives knows them. On the command line the constructor is written as itself, and help says which words are legal:
$ ./colors describe -h
Usage: ./colors <nexus_options> describe <command_options>
General Options:
-h, --help Print help; -hh adds details and examples, -hhh adds schemas
(nexus options: -h @)
Positional arguments:
1: type: Color
values: Red, Green, Blue
Return: Str
$ ./colors describe Blue
"cold"
$ ./colors describe Purple
Error: failed to parse argument #0: serialization error: 'Purple' is not a constructor of this type; expected one of Red, Green, Blue
and a model client is handed a closed set rather than a free-text string:
$ ./colors --mcp-tools
...
"_1": {
"type": "string",
"enum": [
"Red",
"Green",
"Blue"
]
}
...
On the command line the case of a constructor does not matter: blue, BLUE
and Blue are the same value. The convention that constructors are capitalized
is Morloc’s, and a person typing a command should not have to know it. That
leniency is the command line’s alone — a quoted JSON string, whether it is the
whole argument, a field of a record, or an element of a list, is matched
exactly, because JSON is a contract between programs. For the same reason two
constructors of one type may not differ only in case; the compiler rejects the
declaration.
A constructor can carry a description. Write it above the constructor’s = or
|, the way a record field’s description sits above the field:
--' How urgent a task is
data Priority
--' can wait
= Low
--' this week
| Medium
--' today
| High
The description reaches every interface. Terminal help prints a Data Types
block beneath the command at -hhh, --json-help carries it in the types
glossary,
and the MCP tool folds each constructor’s note into the argument’s description
so a model reading the tool sees what the names mean and not only which are
legal:
$ ./tasks pick -hhh
...
Optional arguments:
-p, --priority <Priority> the priority
type: Priority
values: Low, Medium, High [default: Medium]
Data Types:
Priority
How urgent a task is
Low can wait
Medium this week
High today
An option whose type is a data may give its default as the bare constructor
(--' @default medium), and a @many option takes bare constructors one per
occurrence (-p low -p high).
4.14.3. Constructors that take arguments
A constructor may carry fields. Write their types after the constructor name:
module main (area, describe, grow, columns)
import root-py
data Shape = Circle Real | Rect Real Real | Dot
area :: Shape -> Real
area | (Circle r) = 3.14159 * r * r
| (Rect w h) = w * h
| Dot = 0.0
describe :: Shape -> Str
describe | (Circle 0.0) = "a circle of no radius"
| (Circle _) = "a circle"
| (Rect _ _) = "a rectangle"
| Dot = "a dot"
grow :: Real -> Shape -> Shape
grow | k (Circle r) = Circle (k * r)
| k (Rect w h) = Rect (k * w) (k * h)
| _ Dot = Dot
columns :: [Real] -> [Shape]
columns = map (Rect 2.5)
Circle 2.0 builds a value. In a pattern, (Circle r) matches a circle and
binds r to its radius. A constructor pattern with fields needs the
parentheses, since the fields would otherwise read as further arguments of the
clause — which is exactly what they are in grow, whose clauses each carry two
patterns, one per argument.
A constructor is a function of its fields, so it partially applies like any
other. Rect 2.5 in columns is a Real → Shape waiting for a height.
$ morloc make -o shapes shapes.loc
$ ./shapes area '{"Circle":[2.0]}'
12.56636
$ ./shapes area '"Dot"'
0
$ ./shapes grow 1.5 '{"Rect":[1.5,2.5]}'
{"Rect":[2.25,3.75]}
$ ./shapes columns '[1.25,4.5]'
[{"Rect":[2.5,1.25]},{"Rect":[2.5,4.5]}]
A constructor with fields is JSON {"Circle":[2.0]} — one key, the
constructor, and its fields in declaration order. One with no fields is the
bare string "Dot". That is the whole encoding, and it is what you type on the
command line, send over HTTP, and read back out.
|
|
Quote a shape on the command line
A constructor-only |
A field can be matched rather than bound. (Circle 0.0) in describe matches
only a circle of that radius, so it refines Circle without closing it — every
other circle falls through to the clause below, and the compiler still requires
that clause:
$ ./shapes describe '{"Circle":[0.0]}'
"a circle of no radius"
$ ./shapes describe '{"Circle":[2.0]}'
"a circle"
Field counts are checked against the declaration:
arity.loc:8:9: constructor 'Circle' takes 1 argument but the pattern gives 2
|
8 | area | (Circle r h) = r * h
| ^
Fields are positional and have no names, so there is no getter into a data
type — which field exists depends on which constructor you have, and a getter
cannot ask. Matching is the only way in. When you want names, put a record in
the arm.
4.14.4. Matching a value that is not an argument
|-clauses take a definition’s arguments apart. To take apart anything else,
use a match expression (see match expressions), which accepts the same
constructor patterns and the same exhaustiveness rule. Here the value being
matched is the parameter of a local helper:
totalArea :: [Shape] -> Real
totalArea shapes = sum (map one shapes)
where
one :: Shape -> Real
one s = match s
| (Circle r) = 3.14159 * r * r
| (Rect w h) = w * h
| Dot = 0.0
$ ./shapes totalArea '[{"Circle":[1.0]},{"Rect":[2.0,3.0]},"Dot"]'
9.14159
The signature on one is doing work. A constructor pattern is checked against
the type it is matching, and the compiler will not run that in reverse: it
cannot infer Shape from seeing Circle in a pattern, the way it infers
Color for warmest = Red from seeing a constructor in an expression. Leave
the signature off and the build stops with 'Circle' is not a constructor of
followed by an unsolved type variable. The same holds for a definition’s
|-clauses, so give any term you match on a signature.
4.14.5. Recursive types
A constructor may take its own type. That is how you get a tree:
module main (total, depth)
import root-py
data Tree = Leaf | Node Real Tree Tree
total :: Tree -> Real
total | Leaf = 0.0
| (Node v l r) = v + total l + total r
depth :: Tree -> Int
depth | Leaf = 0
| (Node _ l r) = 1 + max (depth l) (depth r)
$ ./tree total '{"Node":[1.5,{"Node":[2.25,"Leaf","Leaf"]},"Leaf"]}'
3.75
$ ./tree depth '{"Node":[1.5,{"Node":[2.25,"Leaf","Leaf"]},"Leaf"]}'
2
Two data types may also refer to each other, which is the shape an abstract
syntax tree takes: an expression holds a term and a term holds an expression.
module main (eval)
import root-py
data Expr = Lit Real | Neg Term | Add Expr Expr
data Term = Wrap Expr | Zero
eval :: Expr -> Real
eval | (Lit v) = v
| (Neg t) = 0.0 - evalT t
| (Add a b) = eval a + eval b
evalT :: Term -> Real
evalT | (Wrap e) = eval e
| Zero = 0.0
$ ./ast eval '{"Add":[{"Lit":[1.5]},{"Neg":[{"Wrap":[{"Lit":[2.0]}]}]}]}'
-0.5
A record may sit on such a cycle too, as long as a data is on it as well — with one caveat. A record on a cycle is a recursive record, and in C and
Rust a recursive record still has to be a type you write yourself; the
compiler does not yet generate one (`record Cpp => Node = "struct"` on a
cycle fails at build time). In C that leaves no way through at all, since a
header you write is included before the data type it would have to name.
Python and R take the shape as it is.
What may not close a cycle is a set of records or aliases alone:
type A = [B]
type B = [A]
mutual.loc:5:1: error:
Mutual recursion between type definitions is not supported unless a `data` type cuts the cycle. Cycle: A, B
|
5 | type A = [B]
| ^
The reason is what a data does that an alias or a record does not. A
constructor’s fields sit behind a pointer in every language, so a value’s size
does not depend on how deep the recursion goes; and the compiler never expands
a data into its constructors when it reduces a type, so a cycle through one
cannot send it round forever. A record’s fields are laid out inline and an
alias is expanded on sight, and neither gives a cycle a place to stop.
4.14.6. Constructor names are global
A constructor name determines its type, which only works if the name is claimed once. Declaring it twice is an error at the second declaration:
dup.loc:6:22: Constructor 'Red' is already declared by another `data` type; constructor names must be unique
|
6 | data Fruit = Apple | Red
| ^
Constructors travel with their type. Exporting Color exports Red, Green
and Blue with it, importing Color brings them in, and a module that only
re-exports Color passes them along, so a data type declared in one module
is usable in another however the two are wired:
module types (Color)
data Color = Red | Green | Blue
module main (favourite)
import .types (Color)
favourite :: Color
favourite = Blue
An import that gives the module an alias puts its constructors behind that
alias, the same way it does every other imported name. Write p.Red in an
expression and in a pattern alike:
module main (warm)
import .types as p
warm :: Color -> Bool
warm | p.Red = True
| _ = False
The alias is the only qualifier there is. A module’s own name is not one — a
name like root-py is not something an expression can spell — and neither is
the type’s. Two modules that each declare a Red can therefore both be used
from a third by giving at least one of them an alias; two data types in the
same module cannot share a constructor name.
4.14.7. Native representations
Every language gets a representation of a data type, and by default the
compiler writes it: an enum class in C++, a #[repr(u8)] enum in Rust, an
ordinal in Python, an ordered factor in R. You declare nothing, and functions
written in Morloc work across all four.
Native code you source is a different matter, because it has to name the type
to take it apart. Rust can name a generated type directly. C++ cannot: a
sourced header is included before the compiler’s own declarations, so a header
that mentions Shape must declare Shape itself, and you tell Morloc that with
a per-language declaration — the same ⇒ form records already use:
data Cpp => Shape = "Shape"
The mapping says only which native name to use. The constructors and their
field types are not repeated, and the native declaration has to match the layout
Morloc expects: a wrapper Shape, one Shape_<Constructor> struct per arm, and
fields named f0, f1 and so on. The foldout at the end of this section gives
that layout for each language.
#pragma once
#include <memory>
#include <variant>
struct Shape_Circle;
struct Shape_Rect;
struct Shape_Dot;
struct Shape {
std::variant<std::shared_ptr<Shape_Circle>,
std::shared_ptr<Shape_Rect>,
std::shared_ptr<Shape_Dot>> v;
};
struct Shape_Circle { double f0; };
struct Shape_Rect { double f0; double f1; };
struct Shape_Dot { };
inline double area(Shape s) {
if (auto p = std::get_if<std::shared_ptr<Shape_Circle>>(&s.v))
return 3.14159 * (*p)->f0 * (*p)->f0;
if (auto p = std::get_if<std::shared_ptr<Shape_Rect>>(&s.v))
return (*p)->f0 * (*p)->f1;
return 0.0;
}
Python needs no declaration. A value with fields arrives as a pair of the constructor’s name and a tuple of its fields:
def grow(k, s):
match s:
case ("Circle", (r,)):
return ("Circle", (k * r,))
case ("Rect", (w, h)):
return ("Rect", (k * w, k * h))
case _:
return s
module main (areaCpp, growPy, bigArea)
import root-py
import root-cpp
data Shape = Circle Real | Rect Real Real | Dot
data Cpp => Shape = "Shape"
source Py from "shapes.py" ("grow" as growPy)
source Cpp from "shapes.hpp" ("area" as areaCpp)
growPy :: Real -> Shape -> Shape
areaCpp :: Shape -> Real
bigArea :: Shape -> Real
bigArea s = areaCpp (growPy 2.0 s)
$ ./crossing growPy 1.5 '{"Circle":[2.5]}'
{"Circle":[3.75]}
$ ./crossing bigArea '{"Rect":[1.5,2.25]}'
13.5
bigArea grew the shape in Python and measured it in C++. One declaration,
two native representations, and a wire form both agree on.
Forget the mapping and the C++ compiler reports
error: redefinition of 'struct Shape' against the generated pool source. It
means the mapping is missing, not that your header is wrong.
4.14.8. Comparing values
== compares two data values by constructor first and then by field, so
Circle 2.0 == Circle 2.0 is True while Rect 1.0 2.0 == Rect 2.0 1.0 is
False.
The ordering operators use the same order the declaration does. Red < Green
is True because Red is declared first, and so is Circle 1.0 < Dot. Two
values of the same constructor are ordered by their fields, so
Circle 1.0 < Circle 2.0.
The answer does not depend on where the comparison runs. Which pool the compiler picks for an expression is its choice rather than yours, so a comparison that meant one thing in Python and another in R would be a bug you could not see in the source.
One gap to know about: the Python and R representations shown above are an interim form, to be replaced by a generated class per arm. Treat the pair shape as something to match on rather than something to build a library around.
How a data value is encoded
For the reader who wants the bytes. It assumes you know what a tagged union is, and nothing about Morloc beyond this section. Two terms are used below: a pool is the process that runs one language’s share of a program, and the nexus is the process that drives them. Values move between them through shared memory, as a fixed-layout binary value with a schema string describing it.
A constructor-only data is one byte. The byte is the constructor’s 0-based
position in the declaration. Alignment is 1 and the width is fixed, so an array
of them is a flat buffer copied in bulk: a [Color] occupies one byte per
element and is byte-for-byte a [U8]. The limit is 256 constructors, because
the tag is a byte.
A data with fields is sixteen bytes, whatever its arms hold: a tag byte at
offset 0, seven bytes of padding, and a relative pointer at offset 8 to the
arm’s fields, laid out as a tuple. An arm with no fields stores a null pointer.
Alignment is 8 and the width is never fixed, so an array of them is walked
rather than copied.
The pointer is the reason recursion terminates. An inline payload would give
Tree the width equation width >= 1 + 2 * width, which has no solution;
behind a pointer, every arm costs the same sixteen bytes. It also makes
appending an arm layout-neutral.
The schema travels with the value and carries the constructor names. Counts
and lengths are one character from a 64-symbol alphabet (0-9, a-z,
A-Z, +, /); a value of 64 or more is written low digit first, with =
before each digit but the last.
| Form | Meaning |
|---|---|
|
A constructor-only |
|
A |
|
Declares a name for the schema that follows, so it can be referred to again. |
|
A back-reference to a declared name. |
The three types in this section:
Color e33Red5Green4Blue
Shape v36Circle1f84Rect2f8f83Dot0
Tree &4Treev24Leaf04Node3f8^4Tree^4Tree
Read Shape as: variant, 3 arms; 6-character name Circle with 1 field of
type f8 (an 8-byte float); 4-character name Rect with 2 fields, both
f8; 3-character name Dot with 0 fields. Tree declares its own name
first, because its arms point back at it.
Nothing in e or v carries the type’s name — only a recursive type
declares one, and only so its arms can refer back. Two data types with the
same constructor names and field types therefore have the same wire form and
are interchangeable at a boundary. Records behave the same way; the encoding is
structural.
Outside shared memory a value takes one of two forms. In MessagePack, which
carries packets and on-disk values, a constructor-only data is its ordinal and
one with fields is the two-element array [tag, fields-or-nil] — ordinals
rather than names, because spelling out a constructor for every element would
multiply the size of a large array. In JSON, which is what the command line, the
HTTP API and the MCP tool descriptions speak, both forms use names.
Wire compatibility follows from the tag being the declaration ordinal. Appending a constructor leaves every existing value byte-identical, and changes the schema only by its arm count and the new name. Reordering or removing constructors changes what old bytes mean, and is a breaking change to every stored value and to every peer that has not been rebuilt.
What a data type looks like in each language
For the reader writing native code against a Morloc data type. Each row is
what a sourced function receives and must return. Morloc generates these
declarations itself unless you map the type with data <Lang> ⇒ T = "<name>",
in which case your file declares it and must match the layout below.
| Language | Constructors without fields | Constructors with fields |
|---|---|---|
Python |
The ordinal, as an |
|
R |
An ordered |
|
C++ |
|
A wrapper struct holding a |
Rust |
|
An enum whose arms each hold one |
The generated C++ and Rust declarations, for the Shape of this section:
struct Shape_Circle;
struct Shape_Rect;
struct Shape_Dot;
struct Shape {
std::variant<std::shared_ptr<Shape_Circle>, std::shared_ptr<Shape_Rect>, std::shared_ptr<Shape_Dot>> v;
};
struct Shape_Circle {
double f0;
};
struct Shape_Rect {
double f0;
double f1;
};
struct Shape_Dot {
};
#[derive(Clone)]
pub enum Shape {
Circle(::std::boxed::Box<(f64,)>),
Rect(::std::boxed::Box<(f64, f64)>),
Dot,
}
Three consequences worth knowing before you write against them.
Arm fields have no names in Morloc, so the C++ form names them f0, f1,
and so on by position, and Rust reaches them as tuple elements. A mapped type
that spells a field differently will not compile, which is the outcome you want.
Every arm is behind a pointer in both compiled languages even when its fields would fit inline. That is what gives a recursive type a finite size, and it is uniform so that no per-type analysis decides it.
A type with parameters maps to a template, exactly as an alias does
(type Cpp ⇒ (List a) = "std::vector<$1>" a), and each instantiation names
the template with that instantiation’s arguments: Box Int is MyBox<int>
and Box Str is MyBox<std::string>. In C++ the arms are templates too,
named by appending _<Constructor> to the wrapper’s head and taking the
same arguments — MyBox<$1> has the arms MyBox_Empty<$1> and
MyBox_Full<$1>. In Rust it is an ordinary generic enum.
data Box a = Empty | Full a
data Cpp => (Box a) = "MyBox<$1>" a
data Rust => (Box a) = "MyBox<$1>" a
template <typename T> struct MyBox_Empty;
template <typename T> struct MyBox_Full;
template <typename T> struct MyBox {
std::variant<std::shared_ptr<MyBox_Empty<T>>,
std::shared_ptr<MyBox_Full<T>>> v;
};
template <typename T> struct MyBox_Empty {};
template <typename T> struct MyBox_Full { T f0; };
#[derive(Clone)]
pub enum MyBox<T> {
Empty,
Full(::std::boxed::Box<(T,)>),
}
The same holds for a record with parameters: record Rust ⇒ (Wrap a) =
"MyWrap<$1>" a names a struct MyWrap<T>. Python and R still declare
nothing; a mapping there is a hint carried on the wire and the value keeps
its structural shape.
Python and R declare nothing. The pair above is a structural interim representation, chosen because it needs nothing the generic marshaller cannot already build; a generated class per arm is the intended end state. The cost of the interim form is that neither language’s compiler — and neither has one — checks that you built an arm correctly, so a field order swapped between two same-typed fields is silently wrong in Python and R where C++ and Rust would reject it.