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

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

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

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

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

ShapeMeaningWhy
struct point do x u64 . y u64 . enda bundle of named fieldsone line per field, name and type
make point do x 1 . y 2 . endbuild a value — fill every fieldno field silently becomes 0
field p x · field s stop xread 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
enum shape do dot . circle r u32 . endone 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 circleis it that variant (bool)for asking without taking values out
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 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.