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.


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 now flow through the closure ABI (this was previously a known limitation). 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.

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

(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 = ... }"

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