Skip to content

Records

Build anonymous structural product types, match them, and return them from helpers without declaring a struct.

Updated View as Markdown

An anonymous record is a product type identified by its fields rather than by a declaration name. Write the type as { field: Type, ... } and the value as { field: value, ... }.

fn position(): { x: int, y: int } {
    return { x: 10, y: 20 }
}

fn distance_from_origin(): int {
    p := position()
    return p.x + p.y
}

No struct declaration is involved. The return type is the field list, so the helper and its caller agree without a shared name.

Structural identity

Two record types with the same field names and field types are the same type, whatever order the fields appear in. Field order is a spelling choice, not part of the identity.

fn f(): int {
    first: { x: int, y: int } = { x: 1, y: 2 }
    second: { y: int, x: int } = first
    return second.x
}

Change a field’s type and you have a different record. The checker reports the mismatch with the canonical $Rec<...> spelling:

fn f(): int {
    r: { x: int } = { x: "hi" }
    return 0
}

Change the field set and you also have a different record. A literal with fewer fields does not satisfy an annotation with more — records do not have width subtyping.

fn f(): int {
    r: { x: int, y: int } = { x: 1 }
    return r.x
}

A record is never interchangeable with a nominal struct that happens to have the same fields. Structs are nominal; records are structural, and the two families do not meet.

struct Point { x: int, y: int }

fn f(): int {
    r: { x: int, y: int } = { x: 1, y: 2 }
    p: Point = r
    return p.x
}

Literals

Each field is name: value, and fields must be separated by commas. A trailing comma is fine. Newline-only separation does not parse, because a brace block is also an expression and the commas are what disambiguate it:

fn f(): int {
    r := {
        status: 200
        body: "ok"
    }
    return r.status
}

Written correctly, the same literal can still span lines:

fn f(): int {
    response := {
        status: 200,
        body: "ok",
        cached: false,
    }
    if response.cached { return 0 }
    return response.status
}

Field shorthand picks up a visible binding of the same name:

fn f(): int {
    status := 200
    body := "ok"
    response := { status, body }
    return response.status + response.body.len()
}

Records nest, both as values and in a struct field:

struct Report {
    window: { start: int, end: int }
    label: string
}

fn span(): int {
    r := Report { window: { start: 1, end: 9 }, label: "day" }
    return r.window.end - r.window.start
}

Access and mutation

Fields use ordinary member syntax, and a mut binding makes them assignable.

fn tally(values: []int): int {
    mut totals := { hits: 0, misses: 0 }
    for v in values {
        if v % 2 == 0 { totals.hits += 1 } else { totals.misses += 1 }
    }
    return totals.hits - totals.misses
}

Records compare with == when their fields do:

fn f(): bool {
    a := { x: 1, y: 2 }
    b := { x: 1, y: 2 }
    return a == b
}

Patterns

Record patterns destructure by field name in match arms. Shorthand binds the field to its own name; field: name renames it; .. accepts fields the pattern does not mention.

fn f(): int {
    response := { status: 200, body: "ok" }
    return match response {
        { status, body } => status + body.len()
    }
}
fn f(): int {
    response := { status: 200, body: "ok", cached: true }
    return match response {
        { status: code, .. } => code
    }
}

Note the asymmetry with construction: a literal must supply every field, while a pattern may ignore the rest with ...

Record patterns nest inside other patterns, which is the usual way to open a record returned as an Option:

fn parse_pair(text: string): { key: string, value: string }? {
    parts := text.split("=")
    if parts.len() != 2 { return None }
    return { key: parts.get(0) ?? "", value: parts.get(1) ?? "" }
}

fn render(text: string): string {
    return match parse_pair(text) {
        Some({ key, value }) => "${key}=${value}"
        None => "(malformed)"
    }
}

Multi-value returns

Records are the readable alternative to a tuple when a helper produces two or three related results. The names travel with the value, so the call site does not have to remember positions.

fn bounds(values: []int): { minimum: int, maximum: int, count: int } {
    mut lo := values.get(0) ?? 0
    mut hi := lo
    for v in values {
        if v < lo { lo = v }
        if v > hi { hi = v }
    }
    return { minimum: lo, maximum: hi, count: values.len() }
}

fn spread(values: []int): int {
    b := bounds(values)
    if b.count == 0 { return 0 }
    return b.maximum - b.minimum
}

The return type may be omitted entirely; the record shape is then inferred from the returned literal.

fn summarize(xs: []int) {
    mut lo := 0
    mut hi := 0
    for v in xs {
        if v < lo { lo = v }
        if v > hi { hi = v }
    }
    return { count: xs.len(), low: lo, high: hi }
}

fn describe(xs: []int): string {
    s := summarize(xs)
    return "${s.count} values in [${s.low}, ${s.high}]"
}

Prefer the explicit form on anything exported: an inferred record shape shows up in diagnostics as $Rec<...>, which is harder to read than a written-out field list.

Records in collections and closures

A record is an ordinary value, so it goes in lists, options, and closure results like any other.

fn doubled_table(xs: []int): int {
    tagged := xs.map(v => { value: v, doubled: v * 2 })
    return tagged.get(0)?.doubled ?? 0
}
fn totals(rows: []{ id: int, amount: int }): int {
    mut sum := 0
    for row in rows {
        sum += row.amount
    }
    return sum
}

fn f(): int {
    return totals([{ id: 1, amount: 10 }, { id: 2, amount: 32 }])
}

Query projections

A SELECT { ... } projection synthesizes a record type, which is why query results can be consumed without declaring a row struct for every projection.

schema shop

model Booking {
    id: int
    passengers: int
    price: float
}

fn revenue(): float ! SqlError {
    rows := FROM Booking SELECT { id, total: passengers * price }?
    mut sum := 0.0
    for row in rows {
        sum += row.total
    }
    return sum
}

The element type here is { id: int, total: float } — the same structural type you would get from writing that literal by hand. See Query Projections.

Records versus structs

Reach for a record when the producer and the consumer are close together: a query projection used by one function, a helper returning two named results, a locally inferred intermediate shape.

Declare a struct when any of these apply:

  • the shape crosses a module boundary or is part of a published API;
  • it needs methods, trait implementations, or derives;
  • its name carries domain meaning worth seeing in diagnostics;
  • it must be recursive.

Records have no declaration site, so they cannot carry inherent methods or implement traits. Promoting a record to a struct is a mechanical change — name the type, keep the fields, and replace { ... } with Name { ... }.

A composed example

A small pipeline that never names a type: parse lines into records, filter them, and fold them into a summary record.

fn parse_line(line: string): { name: string, score: int }? {
    parts := line.split(":")
    if parts.len() != 2 { return None }
    name := parts.get(0) ?? ""
    score := (parts.get(1) ?? "0").to_int() ?? 0
    return { name, score }
}

fn leaderboard(lines: []string) {
    mut best := { name: "", score: 0 }
    mut counted := 0
    mut skipped := 0
    for line in lines {
        match parse_line(line) {
            Some(entry) => {
                counted += 1
                if entry.score > best.score { best = entry }
            }
            None => skipped += 1
        }
    }
    return { winner: best.name, top_score: best.score, counted, skipped }
}

fn report(lines: []string): string {
    r := leaderboard(lines)
    return "${r.winner} won with ${r.top_score} (${r.counted} ok, ${r.skipped} bad)"
}

best = entry type-checks because parse_line returns exactly the record shape best was initialized with — structural identity, no declaration required.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close