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 lit 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 starts with struct <name> — every declaration that builds a type starts with its own word (struct · enum · type · newtype), with no word in front, just like fn and proc. 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 sub field s. stop x . field s. start x . . .
let dy i64 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 lit point do x 1 . y 1 . end . .
let b lit point do x add 1 dx. . . y add 1 dy. . . end . .
return manhattan lit segment do start a. . stop b. . end . . .
end .
fn moved input p point . input k i64 . output point .
do
return lit 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 lit point do x 10 . y 0 . end . .
let q point 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
lit point do x 1 . y 1 . end .makes a value. Every field must be filled when making it. No unfilled field quietly becomes 0. To fill the rest with one value, end with_ <value> .:lit point do x 1 . _ 0 . end ..field s. stop x .reads 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.1.1 Array fields — a field that holds bytes#
When a field’s type is an array with a length, like array 4 u8, its bytes live inside the record. field p. body . is a slice that sees those bytes, and set idx field p. body . i. . v. . writes an element.
examples/ch10/arrayfield.low
module arrayfield .
rem run: copy_keeps 7
rem run: fill_in 3
struct pkt do
len u8 .
body array 4 u8 .
end .
rem an array field's bytes belong to the record --- changing the copy leaves the original alone
export fn copy_keeps input a u8 . output u64 .
do
let p lit pkt do len 2 . body lit array 4 u8 1 a. _ . . end . .
var q pkt p. .
set idx field q. body . 0 . 100 .
return add widen u64 idx field p. body . 0 . . widen u64 idx field q. body . 0 . . . .
end .
proc stamp input p mut pkt . input v u8 . output u64 . effects none .
do
set idx field p. body . 3 . v. .
set field p. len . 4 .
return 0 .
end .
rem writing the array field of a record received `mut` changes the caller's record
export proc fill_in input v u8 . output u64 . effects none .
do
var p lit pkt do len 0 . body lit array 4 u8 _ . . end . .
let z u64 stamp p. v. . .
return add widen u64 field p. len . . widen u64 idx field p. body . 3 . . . .
end .
Output
$ lowentc --run copy_keeps arrayfield.low 7
copy_keeps(7) = 101
$ lowentc --run fill_in arrayfield.low 3
fill_in(3) = 7
- A struct is a value.
var q pkt p. .copies the array field’s bytes too, so changingqleavespalone. A struct held in a field is copied along with it. - Writing a field of a record received
mutchanges the caller’s record. Writing a record received by value changes only the op’s own copy. - The elements are sized numbers or
bool. A list given to the field must match its element type and length (E-TYPE-FIELD). - Seen as bytes (
view,encode,size_of) it is laid out as in C: the array field sits inside the struct’s bytes, aligned to its element.len u8 . body array 4 u8 . tail u16 .is 8 bytes (bodystarts at byte 1,tailat byte 6).
A record bound with let has fields that cannot change either — and that includes the elements of an array field.
examples/ch10/mistake_letfield.low
module mistake_letfield .
rem expect: E-IMMUTABLE
struct pkt do
body array 4 u8 .
end .
rem ✘ writing the array field of a record bound with `let` --- `let` freezes its fields too
fn poke output u64 . do
let p lit pkt do body lit array 4 u8 _ . . end . .
set idx field p. body . 0 . 1 .
return 0 .
end .
Output
$ lowentc --check mistake_letfield.low
mistake_letfield.low:11:0 E-IMMUTABLE: this record was bound with `let`, which is IMMUTABLE — its fields (and the bytes of its array fields) cannot be written either. Use `var` if you mean to change it
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 hin 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. num . gives a bool.
The type a variant holds is written as one word. A variant line has no mark between its fields, so a type spelled in several words such as slice u8 would leave no way to tell where it ends (E-ENUM-PAYLOAD). It is not a limit on size. A type of several words, or a large value, is held through a struct that has it as a field, named and written by that name.
examples/ch10/payload_struct.low
module payload_struct .
rem run: demo 0
rem run: demo 1
rem run: demo 2
struct bytes do
b slice u8 .
end .
struct pt do
x u64 .
y u64 .
z u64 .
end .
enum msg do
data d bytes .
dot p pt .
blank .
end .
fn weigh input m msg . output u64 .
do
match m. do
case data d do return cast u64 len field d. b . . . . end .
case dot p do return add add field p. x . field p. y . . field p. z . . . end .
case blank do return 0 . end .
end .
end .
fn demo input k u8 . output u64 .
do
if eq k. 0 . do return weigh msg.data lit bytes do b "abc" . end . . . . end .
if eq k. 1 . do return weigh msg.dot lit pt do x 1 . y 2 . z 7 . end . . . . end .
return weigh msg.blank. . .
end .
Output
$ lowentc --run demo payload_struct.low 0
demo(0) = 3
$ lowentc --run demo payload_struct.low 1
demo(1) = 10
$ lowentc --run demo payload_struct.low 2
demo(2) = 0
data d bytes .holds a struct with a slice in it, anddot p pt .a struct of three fields. Both are written as one word (bytes,pt).
10.2.1 Variant numbers and cast#
An enum value is not a number. Putting one as it is into a name, argument or return declared as a number, into arithmetic or an index is refused.
examples/ch10/mistake_enumnum.low
module mistake_enumnum .
rem expect: E-TYPE-RETURN
enum volume do
quiet .
loud .
end .
fn code input v volume . output u8 .
do
rem ✘ an enum value is not a number --- write `cast u8 v. .`
return v. .
end .
Output
$ lowentc --check mistake_enumnum.low
mistake_enumnum.low:12:0 E-TYPE-RETURN: the returned value does not match the op's `output` — expected `u8`, found `volume`
Each variant has a number. The first variant is 1 and each next one counts up by one. A number written after a variant’s name becomes that variant’s number, and the following variants count on from it — after ga 11 ., na is 12; after da 101 ., ra is
- Numbers must rise in the order they are written (falling or repeating is
E-ENUM-NUMBER). Reading the number, or finding the
variant for a number, is done with cast (chapter 4).
examples/ch10/enum_number.low
module enum_number .
rem run: code 0
rem run: code 2
rem run: from_disk 2
rem run: is_weapon 12
rem run: is_weapon 21
rem trap: from_disk 9
enum volume do
quiet .
normal .
loud .
end .
fn of input k u8 . output volume . do
if eq k. 0 . do return quiet. . end .
if eq k. 1 . do return normal. . end .
return loud. .
end .
rem read a variant's number --- the first variant is 1
fn code input k u8 . output u8 . do
return cast u8 of k. . . .
end .
rem turn a number read from a file back into a variant --- a number with no variant stops
fn from_disk input n u8 . output bool . do
let v volume cast volume n. . .
return eq v. normal. . .
end .
enum item do
weapon_start 10 .
sword .
stick .
weapon_end .
tool_start 20 .
hammer .
tool_end .
end .
rem values of one enum are ordered --- one comparison filters the variants between start and end
fn is_weapon input n u8 . output bool . do
let it item cast item n. . .
return and gt it. weapon_start. . lt it. weapon_end. . . .
end .
Output
$ lowentc --run code enum_number.low 0
code(0) = 1
$ lowentc --run code enum_number.low 2
code(2) = 3
$ lowentc --run from_disk enum_number.low 2
from_disk(2) = 1
$ lowentc --run is_weapon enum_number.low 12
is_weapon(12) = 1
$ lowentc --run is_weapon enum_number.low 21
is_weapon(21) = 0
$ lowentc --run from_disk enum_number.low 9
== ir diagnostics (1) ==
0:0 E-VM-CAST: cast to an enum: the number is not the number of any variant of that enum (canon 6.2.16 (5a))
codereads a variant’s number withcast u8.quietis 1 andloudis 3.from_diskturns a number read from a file or the wire back into a variant withcast volume. A number that belongs to no variant (9) stops right there — no variant is made up.is_weaponorders values of one enum. Numbers rise in declaration order, so the variants declared betweenweapon_startandweapon_endare filtered in one comparison. Values of different enums are not compared.- An enum with a value-carrying variant takes no numbers and no
cast— one number does not say everything the value holds.
To put an enum inside a byte layout — like the «kind» field of a header that crosses a wire — write its width with one line in the enum block, layout u8 . (u8 · u16 · u32 · u64). A field of that enum sits in that many bytes as the variant’s number. A number that does not fit, or a value-carrying variant, is E-ENUM-LAYOUT. How to read it from bytes is try_view in chapter 13.
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
num 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
num 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 idx nodes. cast usize i. . . .
match n. do
case num 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. num . .
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 idx 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 lit
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 lit 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 `lit` (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 do return 1 . end .
case green do return 2 . end .
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 lit point do x 1 . y 2 . end . .
let q lit 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 lit point do x 1 . y 2 . end . .
let q lit 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 lit 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 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 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 lit 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 lit 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 |
lit 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 |
body array 4 u8 . · set idx field p. body . 0 . 1 . | an array field — the record holds the bytes | copying copies the bytes too |
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 |
ga 11 . · cast u8 v. . · cast volume n. . | variant numbers (from 1, counting on from a written one) · reading one · finding the variant for one | an enum is not a number — crossing is written |
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 lit, 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.