10 Aggregates — struct and enum
What to know first
match must cover every caseLooking back
Besides the exhaustiveness check, what did chapter 7 give as the advantage of match over an if chain?
A. What happens when cases grow. Add a variant to an enum and every match over it stops at translation and points to what must be fixed, while an if chain lets the new case slide silently into its last else. This chapter makes such enums.
The need for this chapter, and its context
struct) and one-of-several (enum) for data to take the shape of the problem. In particular, the pairing of an enum carrying values with match is Lowent’s main replacement for inheritance, and option and result (chapter 11) and traits (chapter 23) all build on it.By the end of this chapter
struct, make one with make and read it with field, and why there is no glued dot. You will see that the variants of an enum can carry values, how to split them with match while binding those values to names, and why variants must be closed with full stops. You will also learn why a type that contains itself is rejected, and how to link tree structures by number.The questions this chapter answers
- Can a field be named
toorin? - Isn’t linking by number less convenient than linking by pointer?
10.1 struct — a collection of named fields#
A struct is a collection of named fields, each with its own type. The declaration’s body is do … end, and each field is closed with a full stop.
examples/ch10/points.low
module points .
rem run: demo_dist 3 4
rem run: demo_move 5
struct point do
x i64 .
y i64 .
end
struct segment do
start point .
stop point .
end
fn manhattan input s segment . output i64 .
do
let dx i64 be sub (field s stop x) (field s start x) .
let dy i64 be sub (field s stop y) (field s start y) .
return add (abs dx) (abs dy) .
end
fn demo_dist input dx i64 . input dy i64 . output i64 .
do
let a point be make point do x 1 . y 1 . end .
let b point be make point do x (add 1 dx) . y (add 1 dy) . end .
return manhattan (make segment do start a . stop b . end) .
end
fn moved input p point . input k i64 . output point .
do
return make point do x (add (field p x) k) . y (field p y) . end .
end
fn demo_move input k i64 . output i64 .
do
let p point be make point do x 10 . y 0 . end .
let q point be moved p k .
return field q x .
end
Output
$ lowentc --run demo_dist points.low 3 4
demo_dist(3, 4) = 7
$ lowentc --run demo_move points.low 5
demo_move(5) = 15
make point do x 1 . y 1 . endmakes a value. Every field must be filled when making it. No unfilled field quietly becomes 0.field s stop xreads thestopfield ofsand thexfield inside it. With several steps, it goes down one field at a time from the left.- A field’s type may be another
struct.segmentholds twopoints. movedbuilds and returns a new value with one field changed. The original stays as it was.
There is no glued dot like p.x. Dots are already used for module names (allocs.byte_allocator) and variant names (shape.dot). If looking inside a value were written with a dot too, what a.b means would only be settled after looking up whether a is a value or a module. The field form settles it the moment you read it.
Q. Can a field be named to or in?
A. No. to and in were infix spellings for reading fields in the old grammar (a to b) and are now removed words. A spelling that is a word cannot be a name, so it is rejected with E-VOCAB-REMOVED. That is why this example uses start and stop rather than from and to.
field is both a place to read and a place to write. A field of a value received as mut is changed with set (field p x) 3 . — reading and writing use the same spelling. The rules for borrowing a value to change it are in chapter 12.
10.2 enum — one of several#
An enum is one of several variants. A variant can carry values, written as <field name> <type> pairs.
examples/ch10/shapes.low
module shapes .
rem run: demo_area 0
rem run: demo_area 1
rem run: demo_area 2
enum shape do
circle r u32 .
rect w u32 h u32 .
dot .
end
fn area input s shape . output u32 .
do
match s do
case circle r . do return mul 3 (mul r r) . end
case rect w h . do return mul w h . end
case dot . do return 0 . end
end
end
fn demo_area input k u8 . output u32 .
do
if eq k 0 . do return area (shape.circle 2) . end
if eq k 1 . do return area (shape.rect 3 5) . end
return area shape.dot .
end
Output
$ lowentc --run demo_area shapes.low 0
demo_area(0) = 12
$ lowentc --run demo_area shapes.low 1
demo_area(1) = 15
$ lowentc --run demo_area shapes.low 2
demo_area(2) = 0
circle r u32 .carries one value namedr, andrect w u32 h u32 .carries two.dot .carries nothing.- A value is made with the variant name, as in
shape.circle 2. case rect w h .in amatchbinds the two carried values towandhwhen the variant isrect.
However many variants there are, the match must cover them all. Leaving out dot is rejected.
examples/ch10/missing_case.low
module missing_case .
rem expect: E-MATCH-INEXHAUSTIVE
enum shape do
circle r u32 .
rect w u32 h u32 .
dot .
end
fn corners input s shape . output u32 .
do
match s do
case circle r . do return 0 . end
case rect w h . do return 4 . end
end
end
Output
$ lowentc --check missing_case.low
missing_case.low:12:0 E-MATCH-INEXHAUSTIVE: this `match` does not handle every variant of the enum — add the missing `case` (that exhaustiveness is what a `match` buys you over an if-chain: add a variant later and the compiler finds every place that must change), or add a `case _ .` wildcard to cover the rest
This check is the greatest value of an enum. When a triangle variant is added later, every match over shape, area and corners included, stops at translation. Nobody has to remember the places to fix.
To ask only which variant without a match, isa n lit gives a bool.
10.3 Variants are closed with full stops#
A newline is not a closer (chapter 3). So writing variants one per line without full stops runs several variants together into one.
examples/ch10/enum_dot.low
module enum_dot .
rem expect: E-ENUM-DOT
enum color do
red
green
blue .
end
Output
$ lowentc --check enum_dot.low
enum_dot.low:6:0 E-ENUM-DOT: this enum variant is not closed with `.`, so the NEXT line was read as part of it — a newline does not close a form (RFC-0103). Close every variant: `red .` · `green .`. Without the dot `red green blue` is ONE variant `red` whose payload field `green` has a type `blue`
red green blue nearly became one variant’s name plus field pairs. The compiler rejects it and tells you to close each variant, as in red ..
A common misconception. An enum is a named integer, like C’s enumerations
enum gives names to integer constants and mixes freely with integers. Lowent’s enum is a type. It does not mix with integers, each variant can carry values of a different shape, and splitting it must cover every variant. It is closer to Rust’s enum or the algebraic data types of the ML family.10.4 Nothing may contain itself#
A variant that holds its own type by value would have infinite size.
examples/ch10/infinite.low
module infinite .
rem expect: E-ENUM-INFINITE
enum expr_tree do
lit v u32 .
add l expr_tree r expr_tree .
end
Output
$ lowentc --check infinite.low
6:0 E-ENUM-INFINITE: an enum variant cannot embed its OWN type BY VALUE — the size would be infinite. Recursion must go through indirection: an index into a node arena (e.g. `u32`) or an `owned` box (RFC-0080 §4.2)
6:0 E-ENUM-INFINITE: an enum variant cannot embed its OWN type BY VALUE — the size would be infinite. Recursion must go through indirection: an index into a node arena (e.g. `u32`) or an `owned` box (RFC-0080 §4.2)
The same goes for struct. Holding itself as a field, directly or through other types, is rejected with E-STRUCT-CYCLE. When you need a tree-like structure, link it by number as the diagnostic suggests. Put the nodes in a slice and point at children by their position in that slice.
examples/ch10/tree.low
module tree .
enum node do
lit v u32 .
add l u32 r u32 .
mul l u32 r u32 .
end
fn eval input nodes slice node . input i u32 . output u32 .
do
let n node be index nodes i .
match n do
case lit v . do return v . end
case add l r . do return add (eval nodes l) (eval nodes r) . end
case mul l r . do return mul (eval nodes l) (eval nodes r) . end
end
end
fn is_leaf input n node . output bool .
do
return isa n lit .
end
Output
$ lowentc --check tree.low
== check: ok ==
The l and r of add l u32 r u32 . are not nodes but positions in nodes. eval finds children by position and recurses. The size is fixed, and the index that follows a position still gets its bounds check. The storage for such node slices — regions — is covered in chapter 18.
Q. Isn’t linking by number less convenient than linking by pointer?
A. In one way it is. If a node is removed and its number reused, whoever held the old number sees the wrong node. The standard library’s pool catches this at run time with generational handles that carry a generation count along with the number (chapter 35). What you gain is large: numbers are safe to copy, can be written straight to a file, and keep nodes together in one slice, which suits the cache.
10.5 Common mistakes#
Counter-example. Leaving a field out of make
examples/ch10/mistake_missfield.low
module mistake_missfield .
rem expect: E-TYPE-FIELD
struct point do
x u64 .
y u64 .
end
fn origin_x output u64 .
do
rem ✘ `y` is not filled in --- `make` must fill every field
let p point be make point do x 1 . end .
return field p x .
end
Output
$ lowentc --check mistake_missfield.low
mistake_missfield.low:12:0 E-TYPE-FIELD: a declared field is missing from this `make` (every field must be given)
C fills missing struct fields with 0, and some languages insert a default. Lowent rejects it with E-TYPE-FIELD, because the source cannot tell whether the missing field was intended or forgotten. If you want 0, write y 0 . — a written 0 is obviously intended. A misspelt field name produces the same code, saying “no such field”.
Counter-example. Passing a value-less variant as Type.variant
examples/ch10/mistake_unitvariant.low
module mistake_unitvariant .
rem run: pick_bare
rem run: pick_qualified
enum light do
red .
green .
end
fn code input c light . output u64 .
do
match c do
case red . return 1 .
case green . return 2 .
end
end
fn pick_bare output u64 .
do
rem a variant that carries no value is written by its bare name
return code green .
end
fn pick_qualified output u64 .
do
rem ✘ in this edition, passing `light.green` stops while running
return code light.green .
end
Output
$ lowentc --run pick_bare mistake_unitvariant.low
pick_bare() = 2
$ lowentc --run pick_qualified mistake_unitvariant.low
pick_qualified() = 2
A variant that carries values is built with the type name in front, as in shape.circle 2, and this book writes a variant carrying nothing by its name alone (green). Both are the same value — pick_bare and pick_qualified both answer 2. Until 2026-09-16 the tool lowered a value-less variant written as light.green to a different representation (a record with a tag), so it stopped at run time with E-VM-TYPE. Now both lower to the same index. Prefer the shorter spelling, and qualify where the enum would otherwise be hard to tell.
Counter-example. Comparing two structs with eq
examples/ch10/mistake_eqstruct.low
module mistake_eqstruct .
rem expect: E-TYPE-KIND
struct point do
x u64 .
y u64 .
end
fn same output bool .
do
let p point be make point do x 1 . y 2 . end .
let q point be make point do x 1 . y 2 . end .
rem ✘ compares two structs with `eq` --- `eq` compares numbers and booleans
return eq p q .
end
Output
$ lowentc --check mistake_eqstruct.low
mistake_eqstruct.low:14:0 E-TYPE-KIND: `eq` / `ne` compare numbers and booleans, not STRUCTS. What "equal" means for a struct differs by type — every field, or only the identifying one? — so the language does not guess: write an op that says it (field by field). This used to pass `--check` and stop at run time with `E-VM-TYPE`
eq compares numbers and booleans. What it means for two structs to be “equal” differs by type — must every field match, or only an identifying one? So it is rejected at translation time with E-TYPE-KIND. Write down what equality means as an op.
examples/ch10/eqstruct_fixed.low
module eqstruct_fixed .
rem run: same
struct point do
x u64 .
y u64 .
end
rem spell out field by field what "equal" means --- it differs from type to type
fn point_eq input a point . input b point . output bool .
do
return expr ((field a x) eq (field b x)) and ((field a y) eq (field b y)) .
end
fn same output bool .
do
let p point be make point do x 1 . y 2 . end .
let q point be make point do x 1 . y 2 . end .
return point_eq p q .
end
Output
$ lowentc --run same eqstruct_fixed.low
same() = 1
Counter-example. Believing that putting a value under another name makes a copy
examples/ch10/mistake_alias.low
module mistake_alias .
rem run: copy_then_change
struct point do
x u64 .
y u64 .
end
fn copy_then_change output u64 .
do
let p point be make point do x 1 . y 2 . end .
rem ✘ meant to make a copy --- in this edition q refers to the same place as p
var q point be p .
set (field q x) 99 .
rem p is a let, and yet this gives 99
return field p x .
end
Output
$ lowentc --run copy_then_change mistake_alias.low
copy_then_change() = 1
var q point be p . builds a new value with p’s fields copied. So changing q’s field to 99 leaves p at 1. A value without ownership is copied; a value with ownership is moved (chapter 19). Until 2026-09-16 the tool made an alias to the same place, and p changed too — the let promise broke there. Lowering now copies the fields.
examples/ch10/alias_fixed.low
module alias_fixed .
rem run: copy_then_change
struct point do
x u64 .
y u64 .
end
fn copy_then_change output u64 .
do
let p point be make point do x 1 . y 2 . end .
rem when you need a new value, build it with make --- copy the fields one by one
var q point be make point do x (field p x) . y (field p y) . end .
set (field q x) 99 .
return field p x .
end
Output
$ lowentc --run copy_then_change alias_fixed.low
copy_then_change() = 1
This is also why moved in this chapter’s first example returns a new value instead of changing a field. Making a new value rather than modifying one sidesteps aliasing altogether.
10.6 This chapter’s syntax at a glance#
| Shape | Meaning | Why |
|---|---|---|
struct point do x u64 . y u64 . end | a bundle of named fields | one line per field, name and type |
make point do x 1 . y 2 . end | build a value — fill every field | no field silently becomes 0 |
field p x · field s stop x | read a field · walk down several levels | no glued dot — the meaning is fixed as you read |
set (field p x) 3 . | write a field (of a value received mut) | reading and writing are spelt the same |
enum shape do dot . circle r u32 . end | one of several — variants may carry values | each variant is closed with a stop |
shape.circle 2 · dot | build a variant that carries a value · one that carries none | the variant name is the constructor |
match s do case circle r . … end | split on variants and bind their values | every variant must be covered |
isa s circle | is it that variant (bool) | for asking without taking values out |
trees linked by index (l u32 · r u32) | slice indexes instead of containing itself | the size is fixed and indexes are bounds-checked |
Table 10.1 — Struct and enum syntax — shape · meaning · why it looks this way
Recap
struct is made with make, filling every field, and read and written with field. There is no glued dot. The variants of an enum can carry values, are made as <type>.<variant>, and are split with a match covering every variant. Variants are closed with full stops. A type holding itself by value has infinite size and is rejected; tree structures are linked by number.