A struct groups related values under one name. Instead of keeping a product's name, price, and stock count in three separate variables that can drift out of sync, you bundle them together into a Product struct. Each piece of data inside the struct is called a field. You read and write fields using dot notation: product.price. Here is a simple product record. All four fields are plain integers or text.
struct Product {
name: text,
price: integer,
stock: integer
}
Field Constraints
You can restrict what values a field may hold. limit(min, max) gives the field a smaller range. A value the compiler cannot prove fits is refused where you write it, and the error names the cures: a fallback (?? <value>), a u8? destination so a value that does not fit reads null, or a mask (& 255) that makes the value provably fit. It never stops a running program: a step the compiler cannot ask about, c.r += 100, takes the type's default when it leaves the range — 0 for a range that includes zero — and loft advises you (narrow-fallback). A nullable field takes null instead. Zero is an ordinary value here. A Colour of 0, 0, 0 is pure black and reads back as three zeros, with nothing extra to write. Fields you omit in a constructor receive zero (or null for nullable fields) by default.
You may meet not null on a field in older code. It has no effect — a type is non-null by default — and the compiler advises removing it.
struct Colour {
r: integer limit(0, 255),
g: integer limit(0, 255),
b: integer limit(0, 255)
}
Methods
A method is a function whose first parameter is named self. Loft uses the type of self to decide which struct the method belongs to. Call it with dot notation: c.to_hex(). A method can read fields via self.field and return any type.
fn to_hex(self: Colour) -> integer {
self.r * 0x10000 + self.g * 0x100 + self.b
}
A method can also return a completely new struct value. Write -> StructName as the return type and construct the value in the body. The original struct is not modified — the caller gets a brand-new copy.
fn dimmed(self: Colour) -> Colour {
Colour {r: self.r / 2 ?? 0, g: self.g / 2 ?? 0, b: self.b / 2 ?? 0 }
}
Defaults and Computed Fields
Write = expression after the type to set a stored default, filled at construction. Use computed(expression) for a field that recalculates every time you read it — it takes no space in the record and always reflects the current state. Inside both, $ refers to the struct.
struct Item {
name: text,
name_length: integer = len($.name)
}
struct Circle {
radius: float,
area: float computed(3.14159265 * $.radius * $.radius)
}
Storing many structs in a vector
Area uses smaller integer types to save memory: u16 holds values 0–65535, and u8 holds values 0–255. This matters when you have millions of records (such as tiles in a game map) and memory usage counts.
struct Area {
height: u16,
terrain: u8,
water: u8,
direction: u8
}
Value structs
Prefix a struct with value to give it value (copy) semantics and zero heap overhead: it is stored inline inside a record or vector element, exactly like a plain integer. Reading one out of a field or element yields an independent COPY — writing to that copy never changes the original. Use a value struct for small wrapper types (a point, a colour, a timestamp) that should be as cheap as the number they wrap.
A plain (reference) struct differs only when you read one OUT of somewhere. Two spellings look alike, so it is worth keeping them apart:
- Binding a whole value COPIES it, always. After
b = athe two names are independent, and writingbnever changesa. The copy goes all the way down, including nested structs and vectors. - Reading a struct-typed field or element gives you a VIEW:
w = o.innerandc = v[i]name the record in place, sow.n = 42changeso.innertoo. A view is not a copy — it points at the interior of what you read it from. (A vector-typed field is a whole value, soav = bx.vcopies.) - Write
&when you want a live link where a bind would copy:b = &aandav = &bx.vboth write through to the source. - ⚠ The same expression can COPY in one position and ALIAS in another.
av = bx.vcopies, because binding a whole value always copies — butf(bx.v)hands the field itself to the function, and a heap parameter shares what it is given (see the Vector page), so the function writes through tobx.v. Binding is the copy; passing is not. Read the position, not just the expression.
value struct Point {
x: integer,
y: integer
}
struct Holder8 {
v: vector<integer>
}
fn touch8(w8: vector<integer>) {
w8[0] = 99;
}
fn main() {
Constructing a struct: name the fields you want to set. Fields you leave out default to zero (or null for nullable fields).
apple = Product {name: "Apple", price: 120, stock: 50 };
assert(apple.price == 120, "price field: {apple.price}");
assert(apple.name == "Apple", "name field: {apple.name}");
You can read and write individual fields after construction.
apple.stock -= 1;
assert(apple.stock == 49, "stock after one sale: {apple.stock}");
A field omitted from the constructor gets zero as its default. loft points this out (omitted-field-zero): the zero is the type's, and nothing in the declaration chose it. That is fine when zero is what you meant, and a trap when zero is a meaningful value in your data — an index where 0 is the first entry, say. Give the field a declared default when you want a different one, the way Item.name_length does above.
col = Colour {r: 128, b: 128 };
assert(col.g == 0, "omitted green channel defaults to zero");
Zero is a real value in a limited field — pure black needs nothing extra.
black = Colour {r: 0, g: 0, b: 0 };
assert(black.r == 0 and black.g == 0 and black.b == 0, "pure black reads back as zeros");
A value the compiler cannot prove in range is REFUSED unless you say what it becomes: ?? 0 makes the fallback explicit, and 300 does not fit, so the fallback is stored.
over = 300;
black.r = over ?? 0;
assert(black.r == 0, "out of range takes the fallback you wrote: {black.r}");
Formatting a struct shows all fields compactly.
assert("{col}" == "{{r:128,g:0,b:128}}", "Struct compact formatting");
:j produces JSON output — a text format used by many web APIs and config tools.
assert("{col:j}" == "{{\"r\":128,\"g\":0,\"b\":128}}", "JSON format");
Call a method on a variable using dot notation.
purple = Colour {r: 128, b: 128 };
assert("{purple.to_hex():x}" == "800080", "hex method result");
You can call a method directly on a constructor expression.
assert(Colour {r: 255, g: 0, b: 0 }.to_hex() == 0xff0000, "method on constructor");
dimmed returns a new Colour with each channel halved. The original variable is unchanged.
dark = purple.dimmed();
assert(dark.r == 64, "dimmed r: {dark.r}");
assert(dark.b == 64, "dimmed b: {dark.b}");
assert(dark.g == 0, "dimmed g: {dark.g}");
Stored defaults are filled once at construction time.
it = Item {name: "hello" };
assert(it.name_length == 5, "stored default name_length: {it.name_length}");
computed() fields recalculate on every access — changing radius updates area.
ci = Circle { radius: 5.0 };
assert(ci.area > 78.0, "computed area: {ci.area}");
ci.radius = 10.0;
assert(ci.area > 314.0, "recomputed area: {ci.area}");
Fill a vector with copies of the same struct using ; count syntax. This creates 16 Area records, all with the same initial values.
map =[Area {height: 0, terrain: 1, water: 1, direction: 1 };
16];
assert("{map[3]}" == "{{height:0,terrain:1,water:1,direction:1}}", "tile record");
map[3].height = 200;
assert(map[3].height == 200, "individual tile update");
A value struct binding is a snapshot (copy), not a shared view.
pts =[Point {x: 1, y: 2 }, Point {x: 3, y: 4 }];
q = pts[0];
q is an independent COPY of element 0
q.x = 99;
assert(pts[0].x == 1, "value struct copy does not write back");
assert(q.x == 99, "the copy itself did change");
A PLAIN struct answers the same two questions differently, so here they are side by side. Reading one out of an element gives a view, and writing the view reaches the element.
shelf =[Product {name: "Pear", price: 90, stock: 10 }];
item = shelf[0];
item.stock = 7;
assert(shelf[0].stock == 7, "a plain struct element read is a view");
Binding the whole value copies it, so the original keeps its own stock.
spare = item;
spare.stock = 3;
assert(item.stock == 7, "a whole-value bind copies");
assert(spare.stock == 3, "and the copy moves on its own");
The same field expression, in the two positions: bound it copies, passed it does not. This pair is easy to generalise the wrong way in either direction.
bx8 = Holder8 { v: [1, 2, 3] };
av8 = bx8.v;
av8[0] = 42;
assert(bx8.v[0] == 1, "binding a vector field copies it");
cx8 = Holder8 { v: [1, 2, 3] };
touch8(cx8.v);
assert(cx8.v[0] == 99, "passing the same field shares it");
Write & to get a live link where a bind would otherwise copy.
linked = &item;
linked.stock = 1;
assert(item.stock == 1, "'&' binds a live link");
Comparing structs
== compares what two structs hold, field by field — a plain struct and a value struct alike, with no OpEq to write. To ask whether two names are the SAME record, put & on both sides.
twin = Product {name: "Pear", price: 90, stock: 3 };
assert(spare == twin, "equal fields make equal structs");
assert(!(&spare == &twin), "…held in two different records");
assert(&linked == &item, "a '&' link names the record it links to");
sizeof
sizeof(Type) returns the byte size used when the type is stored as a struct field or vector element: fields packed largest first, then padded so the size is a multiple of the largest field's alignment. Range-constrained integer types like u8 and u16 report their packed size, not the 8-byte stack slot a local takes.
assert(sizeof(integer) == 8, "integer: 8 bytes (i64 storage)");
assert(sizeof(u8) == 1, "u8: 1 byte (packed)");
assert(sizeof(u16) == 2, "u16: 2 bytes (packed)");
assert(sizeof(Colour) == 3, "Colour: 3 × u8 = 3 bytes");
assert(sizeof(Area) == 6, "Area: u16 + 3 × u8 = 5 bytes, padded to 6");
}
Run it yourself
This page is a loft program. It runs here, in your browser — press Run, then ask it something.
Stop on a line
// Copyright (c) 2024-2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// @NAME: Structs
// @TITLE: Grouping related data with structs
// A struct groups related values under one name. Instead of keeping a
// product's name, price, and stock count in three separate variables that
// can drift out of sync, you bundle them together into a Product struct.
// Each piece of data inside the struct is called a field. You read and write
// fields using dot notation: 'product.price'.
// Here is a simple product record. All four fields are plain integers or text.
struct Product {
name: text,
price: integer,
stock: integer
}
// ## Field Constraints
// You can restrict what values a field may hold.
// 'limit(min, max)' gives the field a smaller range. A value the compiler cannot prove
// fits is refused where you write it, and the error names the cures: a fallback
// ('?? <value>'), a 'u8?' destination so a value that does not fit reads null, or a
// mask ('& 255') that makes the value provably fit. It never stops a running program: a step the compiler cannot ask
// about, 'c.r += 100', takes the type's default when it leaves the range — 0 for a
// range that includes zero — and loft advises you ('narrow-fallback'). A nullable
// field takes null instead.
// Zero is an ordinary value here. A Colour of 0, 0, 0 is pure black and reads back
// as three zeros, with nothing extra to write.
// Fields you omit in a constructor receive zero (or null for nullable fields) by default.
//
// You may meet 'not null' on a field in older code. It has no effect — a type is
// non-null by default — and the compiler advises removing it.
struct Colour {
r: integer limit(0, 255),
g: integer limit(0, 255),
b: integer limit(0, 255)
}
// ## Methods
// A method is a function whose first parameter is named 'self'.
// Loft uses the type of 'self' to decide which struct the method belongs to.
// Call it with dot notation: 'c.to_hex()'.
// A method can read fields via 'self.field' and return any type.
fn to_hex(self: Colour) -> integer {
self.r * 0x10000 + self.g * 0x100 + self.b
}
// A method can also return a completely new struct value.
// Write '-> StructName' as the return type and construct the value in the body.
// The original struct is not modified — the caller gets a brand-new copy.
fn dimmed(self: Colour) -> Colour {
Colour {r: self.r / 2 ?? 0, g: self.g / 2 ?? 0, b: self.b / 2 ?? 0 }
}
// ## Defaults and Computed Fields
// Write '= expression' after the type to set a stored default, filled at construction.
// Use 'computed(expression)' for a field that recalculates every time you read it —
// it takes no space in the record and always reflects the current state.
// Inside both, '$' refers to the struct.
struct Item {
name: text,
name_length: integer = len($.name)
}
struct Circle {
radius: float,
area: float computed(3.14159265 * $.radius * $.radius)
}
// ## Storing many structs in a vector
// 'Area' uses smaller integer types to save memory: 'u16' holds values 0–65535,
// and 'u8' holds values 0–255. This matters when you have millions of records
// (such as tiles in a game map) and memory usage counts.
struct Area {
height: u16,
terrain: u8,
water: u8,
direction: u8
}
// ## Value structs
// Prefix a struct with 'value' to give it value (copy) semantics and zero heap
// overhead: it is stored inline inside a record or vector element, exactly like
// a plain integer. Reading one out of a field or element yields an independent
// COPY — writing to that copy never changes the original. Use a 'value struct'
// for small wrapper types (a point, a colour, a timestamp) that should be as
// cheap as the number they wrap.
//
// A plain (reference) struct differs only when you read one OUT of somewhere.
// Two spellings look alike, so it is worth keeping them apart:
//
// * Binding a whole value COPIES it, always. After 'b = a' the two names are
// independent, and writing 'b' never changes 'a'. The copy goes all the way
// down, including nested structs and vectors.
// * Reading a struct-typed field or element gives you a VIEW: 'w = o.inner'
// and 'c = v[i]' name the record in place, so 'w.n = 42' changes 'o.inner'
// too. A view is not a copy — it points at the interior of what you read it
// from. (A vector-typed field is a whole value, so 'av = bx.v' copies.)
// * Write '&' when you want a live link where a bind would copy: 'b = &a' and
// 'av = &bx.v' both write through to the source.
// * ⚠ The same expression can COPY in one position and ALIAS in another. 'av = bx.v'
// copies, because binding a whole value always copies — but 'f(bx.v)' hands the
// field itself to the function, and a heap parameter shares what it is given (see
// the Vector page), so the function writes through to 'bx.v'. Binding is the copy;
// passing is not. Read the position, not just the expression.
value struct Point {
x: integer,
y: integer
}
struct Holder8 {
v: vector<integer>
}
fn touch8(w8: vector<integer>) {
w8[0] = 99;
}
fn main() {
// Constructing a struct: name the fields you want to set.
// Fields you leave out default to zero (or null for nullable fields).
apple = Product {name: "Apple", price: 120, stock: 50 };
assert(apple.price == 120, "price field: {apple.price}");
assert(apple.name == "Apple", "name field: {apple.name}");
// You can read and write individual fields after construction.
apple.stock -= 1;
assert(apple.stock == 49, "stock after one sale: {apple.stock}");
// A field omitted from the constructor gets zero as its default. loft points this
// out ('omitted-field-zero'): the zero is the type's, and nothing in the
// declaration chose it. That is fine when zero is what you meant, and a trap when
// zero is a meaningful value in your data — an index where 0 is the first entry,
// say. Give the field a declared default when you want a different one, the way
// 'Item.name_length' does above.
col = Colour {r: 128, b: 128 };
assert(col.g == 0, "omitted green channel defaults to zero");
// Zero is a real value in a limited field — pure black needs nothing extra.
black = Colour {r: 0, g: 0, b: 0 };
assert(black.r == 0 and black.g == 0 and black.b == 0, "pure black reads back as zeros");
// A value the compiler cannot prove in range is REFUSED unless you say what it becomes:
// `?? 0` makes the fallback explicit, and 300 does not fit, so the fallback is stored.
over = 300;
black.r = over ?? 0;
assert(black.r == 0, "out of range takes the fallback you wrote: {black.r}");
// Formatting a struct shows all fields compactly.
assert("{col}" == "{{r:128,g:0,b:128}}", "Struct compact formatting");
// ':j' produces JSON output — a text format used by many web APIs and config tools.
assert("{col:j}" == "{{\"r\":128,\"g\":0,\"b\":128}}", "JSON format");
// Call a method on a variable using dot notation.
purple = Colour {r: 128, b: 128 };
assert("{purple.to_hex():x}" == "800080", "hex method result");
// You can call a method directly on a constructor expression.
assert(Colour {r: 255, g: 0, b: 0 }.to_hex() == 0xff0000, "method on constructor");
// 'dimmed' returns a new Colour with each channel halved.
// The original variable is unchanged.
dark = purple.dimmed();
assert(dark.r == 64, "dimmed r: {dark.r}");
assert(dark.b == 64, "dimmed b: {dark.b}");
assert(dark.g == 0, "dimmed g: {dark.g}");
// Stored defaults are filled once at construction time.
it = Item {name: "hello" };
assert(it.name_length == 5, "stored default name_length: {it.name_length}");
// computed() fields recalculate on every access — changing radius updates area.
ci = Circle { radius: 5.0 };
assert(ci.area > 78.0, "computed area: {ci.area}");
ci.radius = 10.0;
assert(ci.area > 314.0, "recomputed area: {ci.area}");
// Fill a vector with copies of the same struct using '; count' syntax.
// This creates 16 Area records, all with the same initial values.
map =[Area {height: 0, terrain: 1, water: 1, direction: 1 };
16];
assert("{map[3]}" == "{{height:0,terrain:1,water:1,direction:1}}", "tile record");
map[3].height = 200;
assert(map[3].height == 200, "individual tile update");
// A value struct binding is a snapshot (copy), not a shared view.
pts =[Point {x: 1, y: 2 }, Point {x: 3, y: 4 }];
q = pts[0];
// q is an independent COPY of element 0
q.x = 99;
assert(pts[0].x == 1, "value struct copy does not write back");
assert(q.x == 99, "the copy itself did change");
// A PLAIN struct answers the same two questions differently, so here they are
// side by side. Reading one out of an element gives a view, and writing the
// view reaches the element.
shelf =[Product {name: "Pear", price: 90, stock: 10 }];
item = shelf[0];
item.stock = 7;
assert(shelf[0].stock == 7, "a plain struct element read is a view");
// Binding the whole value copies it, so the original keeps its own stock.
spare = item;
spare.stock = 3;
assert(item.stock == 7, "a whole-value bind copies");
assert(spare.stock == 3, "and the copy moves on its own");
// The same field expression, in the two positions: bound it copies, passed it does
// not. This pair is easy to generalise the wrong way in either direction.
bx8 = Holder8 { v: [1, 2, 3] };
av8 = bx8.v;
av8[0] = 42;
assert(bx8.v[0] == 1, "binding a vector field copies it");
cx8 = Holder8 { v: [1, 2, 3] };
touch8(cx8.v);
assert(cx8.v[0] == 99, "passing the same field shares it");
// Write '&' to get a live link where a bind would otherwise copy.
linked = &item;
linked.stock = 1;
assert(item.stock == 1, "'&' binds a live link");
// ## Comparing structs
// '==' compares what two structs hold, field by field — a plain struct and a value
// struct alike, with no 'OpEq' to write. To ask whether two names are the SAME
// record, put '&' on both sides.
twin = Product {name: "Pear", price: 90, stock: 3 };
assert(spare == twin, "equal fields make equal structs");
assert(!(&spare == &twin), "…held in two different records");
assert(&linked == &item, "a '&' link names the record it links to");
// ## sizeof
// 'sizeof(Type)' returns the byte size used when the type is stored as a struct
// field or vector element: fields packed largest first, then padded so the size
// is a multiple of the largest field's alignment. Range-constrained integer types like
// u8 and u16 report their packed size, not the 8-byte stack slot a local takes.
assert(sizeof(integer) == 8, "integer: 8 bytes (i64 storage)");
assert(sizeof(u8) == 1, "u8: 1 byte (packed)");
assert(sizeof(u16) == 2, "u16: 2 bytes (packed)");
assert(sizeof(Colour) == 3, "Colour: 3 × u8 = 3 bytes");
assert(sizeof(Area) == 6, "Area: u16 + 3 × u8 = 5 bytes, padded to 6");
}