Structs in Turmeric

Structs are named, ordered collections of fields. They are the primary way to group related data under a single type name, and they integrate cleanly with typeclasses, ownership, and the effects system.


Defining a struct

(defstruct Name [field1 : type1 field2 : type2 ...])

Fields are listed as alternating name/type pairs inside the vector literal. All field names must be distinct.

(defstruct Point [x : int y : int])
(defstruct Pixel [r : uint8 g : uint8 b : uint8 a : uint8])
(defstruct Vec2  [x : float32 y : float32])

A defstruct at file scope registers the type immediately so that later definitions -- including self-referential or mutually-recursive structs -- can refer to it.


Ownership annotations

Every struct has a copy kind that governs how the value can be used:

Annotation Meaning
:copy Bitwise-copyable; can be freely duplicated (all fields must be copy types)
:move Move-only (default when no annotation is given)
:linear Exactly-once consumed; must be used exactly once
(defstruct Point   :copy   [x : int y : int])      ; freely copyable
(defstruct Packet  :move   [ptr : ptr<void>])      ; ownership transferred on use
(defstruct Socket  :linear [fd : int])             ; must be consumed exactly once

Omitting the annotation is the same as :move:

(defstruct Pair [first : int second : int])   ; move-only by default

The compiler enforces that a :copy struct's fields are all themselves copy-compatible types. Using a non-copy field (such as :ref<int>) in a :copy struct is a compile error.

Structs are passed by value

A struct argument is copied into the callee, at every copy kind. A callee therefore mutates its own copy, and the caller's value is unchanged:

(defstruct Ctr [n : int])

(defn set-it! [^mut a : Ctr] : int
  (set! (.n a) 3)     ; mutates the CALLEE's copy
  0)

(defn main [] : int
  (let [^mut c (Ctr 0)]
    (set-it! c)
    (println (.n c)))  ; => 0, not 3
  0)

^mut on a parameter is not an out-parameter. It means "this binding is mutable inside this body" -- useful for treating a parameter as a mutable local seeded from the argument, which is observable within the function:

(defn bump-and-read [^mut a : Ctr] : int
  (set! (.n a) 3)
  (.n a))             ; => 3; the write is visible HERE, just not to the caller

Nesting does not change this: an Outer holding an Inner by value is one flat value, so copying the outer copies the inner and (set! (.n (.inner o)) v) is equally invisible to the caller.

To let a callee mutate something the caller can observe, reach for a type whose sharing is part of its meaning:

you want use
shared, reference-counted mutation rc<T> -- see Reference-counted structs
interior mutability by declaration a :heap struct
to return the new value instead an ordinary return, which is usually clearest

A &Struct receiver is rejected outright -- set! (.field s) requires a struct or an rc<Struct> -- so the reference route is not available by accident.


Supported field types

Syntax Meaning
:int 64-bit signed integer
:bool Boolean
:float 64-bit float
:float32 32-bit float
:uint8, :int16, :int32, ... Sized numeric types
:cstr C string pointer (const char *)
:ptr<void> Raw pointer
:rc<T> Reference-counted pointer to T
:ref<T> Owned reference to T (non-RC)
:lref<T> Linear reference to T (exactly-once)
:weak<T> Weak reference (non-owning, for rc<T> cycles)
:fn Function pointer
:fn #fx{Effect} Function pointer with an explicit effect-row annotation

Drop glue (automatic cleanup of rc<T>, ref<T>, and weak<T> fields) is generated by the compiler whenever any such field is present.


Creating instances

Use make-struct to construct a struct value. Fields are supplied positionally in the order they appear in defstruct.

(defstruct Point :copy [x : int y : int])

(let [p (make-struct Point 3 4)]
  ...)
(defstruct Pixel [r : uint8 g : uint8 b : uint8 a : uint8])

(let [px (make-struct Pixel 255u8 128u8 64u8 255u8)]
  ...)

The argument count must exactly match the field count; a mismatch is a compile-time error.

Auto-bound constructor

Every defstruct also binds the struct name as a constructor function in the value namespace, so you can drop the make-struct keyword entirely:

(defstruct Point :copy [x : int y : int])

(let [p (Point 3 4)]          ; identical to (make-struct Point 3 4)
  ...)

The struct name lives in the type namespace and the constructor in the value namespace, so Point still works as a type annotation (p : Point) with no conflict. If the struct name is already bound as a value elsewhere, opt out with :no-auto-ctor; construction then goes only through make-struct:

(defstruct Point :copy :no-auto-ctor [x : int y : int])
;; (Point 3 4) is now an error; use (make-struct Point 3 4)

Note: an under-applied positional constructor curries. (Point 3) on a two-field struct returns a closure expecting the remaining field, and ((Point 3) 4) -- or (let [mk (Point 3)] (mk 4)) -- yields the Point, with field access working on the result. By-value struct results flow through the closure ABI. The keyword form does not curry: a keyword call must supply every field.

Keyword arguments

Both make-struct and the auto-bound constructor accept :field value pairs. Keyword order is free; the elaborator reorders into declared-field order:

(defstruct Person :copy [name : cstr age : int])

(Person :name "Bob" :age 40)            ; auto-bound ctor, keyword form
(make-struct Person :age 40 :name "Bob") ; make-struct, reversed order

Keyword construction is checked strictly: every field must be supplied (TUR-E0292), an unknown field (TUR-E0294) or a duplicate field (TUR-E0293) is an error, and positional and keyword forms cannot be mixed in one call (TUR-E0299). Use one form or the other.

name : cstr here is fine -- it holds a string literal, whose bytes are static. But a struct field that must own a computed or stored string (form input, a decoded value, anything that outlives its source) should be String, not cstr, so the struct owns its bytes instead of borrowing a pointer that can dangle. See strings-guide.md.

Functional update with with

with returns a new struct value with some fields overridden and the rest copied from a source value. It is only valid on :copy structs (copying the unchanged fields out of a move-only source would consume it):

(defstruct Person :copy [name : cstr age : int active : int])

(let [p (Person "Bob" 40 1)
      q (with p [name "Alice"])        ; => Person "Alice" 40 1
      r (with p [active 0 age 41])]    ; override order is free
  ...)

with lowers to a constructor call that takes the listed fields from the override list and every other field from the source, so override values are type-checked exactly as the constructor would check them. Applying with to a non-:copy struct is rejected (TUR-E0296), as are unknown (TUR-E0297) and duplicate (TUR-E0298) override fields.


Accessing fields

Use .fieldname to read a field from a struct value:

(defstruct Point :copy [x : int y : int])

(let [p (make-struct Point 3 4)]
  (println (.x p))    ; 3
  (println (.y p)))   ; 4

The .fieldname form is also valid for arithmetic:

(defn distance-sq [p] : int
  (+ (* (.x p) (.x p))
     (* (.y p) (.y p))))

Destructuring with match

A struct is the single-variant case of a tagged union, so match takes one apart too -- the sole pattern names the struct and binds its fields. A single-variant match is trivially exhaustive (no catch-all needed):

(defstruct Person :copy [name : cstr age : int])

(let [p (make-struct Person "Ada" 36)]
  (match p
    (Person name age)        ; positional binding
      (println name)))

(match p
  (Person :age a :name n)    ; by-name binding, order free
    (println a))

(match p
  (Person _ age)             ; `_` ignores a field
    (println age))

This is the same surface as matching a record-style defdata variant; see Sum Types. Use .fieldname for a single read and match when you want several fields bound at once.


Borrowing fields

Prefix .fieldname with & to take an immutable borrow of a field without moving the struct:

(defstruct Point :copy [x : int y : int])

(let [p (make-struct Point 3 4)]
  (let [rx &(.x p)]
    (println @rx)))   ; prints 3

Linear fields (lref<T>)

A :move struct may contain :lref<T> fields. Extracting such a field transfers linear ownership out of the struct and marks the struct binding as moved. A second extraction from the same binding is a use-after-move error (TUR-E0005).

(defstruct PtrBox :move [ptr : lref<int>])

;; OK: single extraction
(let [b (make-struct PtrBox (lref/new 42))]
  (println (deref (.ptr b))))

;; ERROR TUR-E0005: second extraction of a moved struct
(defstruct Box :move [val : lref<int>])
(let [b (make-struct Box (lref/new 42))]
  (let [x (.val b)]
    (let [y (.val b)]   ; use-after-move
      (println (deref x)))))

Reference-counted structs

Wrap a struct in rc/of to heap-allocate it with reference counting. The RC wrapper manages drop glue automatically when the count reaches zero.

(defstruct Wrapper :move [val : rc<int>])

(let [inner (rc/of 10)]
  (let [w (rc/of (make-struct Wrapper inner))]
    (println (rc/strong-count w))))

If the struct itself contains :rc<T> fields, the compiler generates nested drop glue to decrement those inner counts:

(defstruct Node :move [val : rc<int>])

(let [inner (rc/of 42)
      outer (rc/of (make-struct Node inner))]
  (println (rc/strong-count outer)))

Structs and typeclasses

Implement typeclasses for your struct types with definstance. Inside inline-C methods, struct parameters arrive as C struct values, so field access uses dot notation.

Clone

(defstruct Pair [first : int second : int])

(definstance Clone [Pair]
  (clone [x] : int
    ```c
    struct { int64_t first; int64_t second; } *dst = malloc(sizeof(Pair));
    dst->first = x.first;
    dst->second = x.second;
    return (int64_t)(intptr_t)dst;
    ```))

Eq

(definstance Eq [Pair]
  (eq? [x y] (pair-eq? x y (fn [a b] (= a b)))))

Show

The stdlib Show renders to an OWNED String ((show x) : String): the caller string/releases the result. Build a struct instance directly with a StringBuilder over each field's own show, releasing every intermediate. Most of the time you do not write this by hand -- derive-show (below) generates exactly this.

(load "stdlib/typeclass.tur")

(defstruct Point :copy [x : int y : int])

(definstance Show [Point]
  (show [__p]
    (let [b (builder/new)]
      (do
        (builder/push-cstr! b "Point { x = ")
        (let [sx (show (.x __p))] (do (builder/push-string! b sx) (string/release sx)))
        (builder/push-cstr! b ", y = ")
        (let [sy (show (.y __p))] (do (builder/push-string! b sy) (string/release sy)))
        (builder/push-cstr! b " }")
        (builder/finish b)))))

(let [p (make-struct Point 3 4)]
  (show-line p))   ; Point { x = 3, y = 4 }  (show + println + release)

show-line (and print-show) wrap show + print + release so a render-and-print call site stays a one-liner despite the owned result. To keep the String, hold it and release when done: (let [s (show p)] (do ... (string/to-cstr s) ... (string/release s))).

defstruct Point :copy [x :int y :int]

definstance Show [Point]
  show [__p] :cstr
    ```c
    int nx = snprintf(NULL, 0, "%lld", (long long)__p.x);
    int ny = snprintf(NULL, 0, "%lld", (long long)__p.y);
    size_t len = 13 + (size_t)nx + 6 + (size_t)ny + 3 + 1;
    char *buf = (char *)malloc(len);
    snprintf(buf, len, "Point { x = %lld, y = %lld }",
             (long long)__p.x, (long long)__p.y);
    return (const char *)(intptr_t)buf;
    ```

let [p make-struct(Point 3 4)]
  println(.show(p))   ; Point { x = 3, y = 4 }

Deriving Show, Debug, Display

Writing the inline C above for every struct is tedious. The derive-show, derive-debug, and derive-display macros in stdlib/macros.tur generate the instance from a struct name and a list of field descriptors.

Each field descriptor is either a bare symbol -- x becomes label "x" with accessor (.x s) -- or a [label .accessor] pair to use a different label or non-default accessor.

(defstruct Point :copy [x : int y : int])
(derive-show    Point x y)
(derive-debug   Point x y)
(derive-display Point x y)

(let [p (make-struct Point 3 4)]
  (println (.show    p))    ; Point { x = 3, y = 4 }
  (println (.debug   p))    ; (Point (x 3) (y 4))
  (println (.display p)))   ; Point { x = 3, y = 4 }

derive-show produces "TypeName { k = v, ... }". derive-debug produces the s-expression-flavoured "(TypeName (k v) ...)". derive-display matches derive-show but dispatches through the Display typeclass.

Each field must itself have an instance of the relevant typeclass -- the macro expansion calls .show (or .debug/.display) on each field value and joins the results with str-concat. Show, Debug, and Display instances exist in stdlib for primitives, Pair, Option, Result, List, and Vec, so nested structs and collections print recursively out of the box.

To alias a field name or access through a non-standard reader, use the [label .accessor] pair form:

(defstruct MyStruct [name : cstr internal-label : cstr count : int])
(derive-show MyStruct name [display-name .internal-label] count)
;; => "MyStruct { name = ..., display-name = ..., count = ... }"

derive-show (owned) and derive-show-cstr (local class)

derive-show is the deriver for the stdlib Show. Since the stdlib Show returns an owned String, derive-show generates the Show [T] instance shown above -- a StringBuilder over each field's show, releasing every intermediate, one owned String result, no per-field concat leak. It needs String / StringBuilder in scope, i.e. a program that loaded stdlib/typeclass.tur (or stdlib/string.tur). Field descriptors -- bare symbols and the [label .accessor] alias form -- are the same for both derivers.

(load "stdlib/typeclass.tur")

(defstruct Point :copy [x : int y : int])
(derive-show Point x y)

(let [p (make-struct Point 3 4)]
  (show-line p))              ; Point { x = 3, y = 4 }

The sibling derive-show-cstr emits a cstr-bodied Show instance joined with str-concat. It is for programs that define their own minimal local Show class and never load the String stack. The emitted body needs three things in scope at the call site:

  1. a Show class (defclass Show [a] (show [x] : cstr));
  2. a str-concat : (cstr cstr) -> cstr;
  3. a Show instance for each field's type.

stdlib/str-build.tur is the intended source of str-concat (and int->str): a dependency-free leaf that pulls in no typeclass instances and carries interpreter natives, so a program built on it runs on both the compiled and --interpret paths with no inline-C.

(load "stdlib/str-build.tur")            ; str-concat + int->str (both paths)
(defclass Show [a] (show [x] : cstr))
(definstance Show [int] (show [x] : cstr (int->str x)))

(defstruct Point :copy [x : int y : int])
(derive-show-cstr Point x y)

(let [p (make-struct Point 3 4)]
  (println (.show p)))                   ; Point { x = 3, y = 4 }

A missing str-concat or field Show instance is a compile error attributed to the macro, not the call site. Using derive-show-cstr against the owned stdlib Show (or derive-show against a local cstr Show) is a type error -- match the deriver to the Show class in scope.

REPL auto-show

The native REPL and web REPL automatically call show on the result of each top-level expression when an applicable Show instance exists, so:

> (make-struct Point 3 4)
Point { x = 3, y = 4 }

If no Show instance is registered for the result type, the REPL falls back to printing the raw value. Note: heap-allocated stdlib types returned through constructors that elaborate to :int (for example pair-new) are seen by typeclass dispatch as int and will print as a raw pointer; use (make-struct Pair 1 2) (or a typed alias) when you want Show-dispatch to fire on the constructed value.

Bifunctor

(definstance Bifunctor [Pair]
  (bimap [container fn-left fn-right]
    (__bifunctor_pair_bimap container fn-left fn-right)))

Function-pointer fields

A :fn field stores a function pointer. Annotate the field with #fx{Effect} to declare the effect row the stored function may perform. Calling the field propagates that effect row to the enclosing function.

(defeffect Emit [s :cstr] :nil)

(defstruct Emitter :copy [run : fn #{Emit}])

(defn main [] : int
  (let [em (make-struct Emitter (fn [s] (perform (Emit s))))]
    (handle
      (do (.run em "hello") 0)
      (Emit [s] k) (do (println s) (resume k nil)))))

Cross-module structs

A struct defined in one module can be imported and used in another. Construct it with make-struct and access its fields with .fieldname exactly as if it were locally defined.

;; geom module defines Point
(defmodule app
  (import geom :refer [Point])
  (defn main [] : int
    (let [p (make-struct Point 3 4)]
      (println (+ (.x p) (.y p)))
      0)))

Docstrings

Follow the ;;; convention immediately above the defstruct form:

;;; Point -- a 2D integer coordinate.
;;;
;;; Parameters:
;;;   x -- horizontal position
;;;   y -- vertical position
;;;
;;; Example:
;;;   (make-struct Point 3 4)  ; => Point with x=3, y=4
;;;
;;; Since: Phase B1
(defstruct Point :copy [x : int y : int])

See the docstring standard in CLAUDE.md for the full required-fields table.


Migrating legacy :int-pointer struct code

Older C-backed spice code predates typed structs. It stored a struct as an :int pointer, sized allocations with (sizeof T), read fields through generated (Struct-field obj) accessor functions, and indexed heap float buffers with a (float64* ptr i) intrinsic. None of these are Turmeric language forms -- sizeof is valid only inside an inline-C block, and the Struct-field accessor and float64*/float32* indexing forms never existed at the language level. Code using them fails with unknown function or operator '<name>' (the compiler attaches a migration hint pointing here).

Translate each form to the supported equivalent:

Legacy form Supported equivalent
(malloc (sizeof T)) -> struct as :int (make-struct T ...); let the type system own the layout
(Struct-field obj) accessor function (.field obj) read
(set! (Struct-field obj) v) over an :int pointer give the field a real type and mutate the backing value (e.g. (vec-set! (.data obj) i v))
heap float buffer as :int + (float64* ptr i) a stdlib (Vec float) field with (vec-get v i) / (vec-set! v i x)
(declare malloc free) (extern-c malloc [size : int] : ptr<void>) (only when you genuinely need raw C malloc)

A matrix wrapper, before and after:

;; Before -- struct pointer stored as :int, data malloc'd, fields via accessors
(defstruct mat [rows : int  cols : int  data : int])
(defn mat-get [m i] : float (float64* (mat-data m) i))

;; After -- typed struct over a heap (Vec float)
(defstruct mat [rows : int  cols : int  data : (Vec float)])
(defn mat-get [^borrow m : mat  i : int] : float
  (vec-get (.data m) i))

Reading and mutating through a borrow

A struct that owns a (Vec float) (or any non-:copy field) is move-only, so a plain (defn mat-get [m : mat ...] ...) consumes m and a second call trips TUR-E0005 use-after-move. Take the receiver by borrow with the ^borrow parameter annotation -- not a &mat / ^&mat type spelling -- and .field reads (and vec-set! writes through (.data m)) work without moving:

(defn mat-get [^borrow m : mat  i : int] : float
  (vec-get (.data m) i))

(defn mat-set! [^borrow m : mat  i : int  v : float] : void
  (vec-set! (.data m) i v))

The call site passes the struct directly -- (mat-get m 0) -- and keeps ownership, so the same m can be read and mutated repeatedly.


Common errors

Error Cause
TUR-E0005 Use-after-move: reading a field from a struct that was already moved (or extracting a linear field twice). For a move-only struct, take the receiver ^borrow (see Migrating legacy :int-pointer struct code)
unknown function or operator 'sizeof' / 'float64*' / '<Struct>-<field>' A legacy :int-pointer form; see the migration table above
Field count mismatch make-struct given fewer or more arguments than the struct has fields
:copy constraint violation A :copy struct contains a field type that is not copy-compatible (e.g. :ref<int>)

See also