Lowent Manual←↑→

10 Aggregates — struct and enum

What to know first

chapter 7, Flow · match must cover every case
chapter 9, Sequences · a slice carries its length

Looking 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

Numbers and slices alone cannot write shapes like “a point”, “a segment”, or “a circle or a rectangle or a dot”. You need a collection of named fields (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

You will learn to declare a 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

  1. Can a field be named to or in?
  2. 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

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 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

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

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

  1. 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))

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

C’s 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#

ShapeMeaningWhy
struct point do x u64 . y u64 . end .a bundle of named fieldsone line per field, name and type
lit point do x 1 . y 2 . end .build a value — fill every fieldno field silently becomes 0
field p. x . · field s. stop x .read a field · walk down several levelsno 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 bytescopying copies the bytes too
enum shape do dot . circle r u32 . end .one of several — variants may carry valueseach variant is closed with a stop
shape.circle 2 . · dotbuild a variant that carries a value · one that carries nonethe variant name is the constructor
match s do case circle r . … endsplit on variants and bind their valuesevery 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 onean enum is not a number — crossing is written
trees linked by index (l u32 · r u32)slice indexes instead of containing itselfthe size is fixed and indexes are bounds-checked

Table 10.1 — Struct and enum syntax — shape · meaning · why it looks this way

Recap

A 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.