Records
Records are user-defined, named product types created with the define-record-type special form. They provide constructors, type predicates, and field accessors.
Records vs Maps
If you need an open data shape that's easy to serialize and manipulate generically, use maps. If you want a closed domain type with a predicate and fixed fields, use records.
Defining Record Types
define-record-type
Define a new record type, generating a constructor, predicate, and one accessor per field.
(define-record-type point
(make-point x y) ; constructor (positional args)
point? ; predicate
(x point-x) ; (field-name accessor-name)
(y point-y))General syntax:
(define-record-type <type-name>
(<constructor> <field-name> ...)
<predicate>
(<field-name> <accessor>) ...)What Gets Defined
For the point example above:
| Binding | Signature | Purpose |
|---|---|---|
make-point | (x y) → point | Constructor |
point? | (value) → bool | Type predicate |
point-x | (point) → value | Field accessor |
point-y | (point) → value | Field accessor |
(define p (make-point 3 4))
(point? p) ; => #t
(point? 42) ; => #f
(point-x p) ; => 3
(point-y p) ; => 4Constructor Arity
The constructor is positional — its arity must match exactly:
(make-point 1 2) ; ok
(make-point 1) ; error: wrong arity
(make-point 1 2 3) ; error: wrong arityImmutability
Sema records are immutable. To "update" a record, construct a new one:
(define (move-point p dx dy)
(make-point (+ (point-x p) dx)
(+ (point-y p) dy)))
(move-point (make-point 10 20) 5 -2)
; => a new point record with x=15, y=18Equality
Two records are equal? if they have the same type and their fields are pairwise equal?:
(define a (make-point 1 2))
(define b (make-point 1 2))
(define c (make-point 9 9))
(equal? a b) ; => #t (same type, same fields)
(equal? a c) ; => #f (same type, different fields)Records of different types are never equal, even if they have the same field values.
Introspection
record?
Test if a value is any record instance (of any record type).
(record? (make-point 3 4)) ; => #t
(record? {:x 3 :y 4}) ; => #f
(record? 42) ; => #ftype
Return the type of a value as a keyword. For records, returns the record's type name:
(type (make-point 3 4)) ; => :point
(type [1 2 3]) ; => :vector
(type {:a 1}) ; => :mapRecords vs Maps
Both model "structured data", but they serve different purposes.
Use records when…
- You want a distinct type:
person?,invoice?,token? - Your data has a fixed schema enforced at construction
- You want named field accessors and clear domain boundaries
Use maps when…
- You need easy serialization (JSON, TOML, etc.)
- You want to add/remove keys dynamically
- You want generic operations like
get,assoc,merge,keys,map/get-in,map/update-in - You're interacting with external APIs
Common pattern
Maps at the boundary, records internally. Parse/validate external maps into records early, and convert records back to maps for output.
Nested Records
Records can contain any values, including other records:
(define-record-type address
(make-address line1 city country)
address?
(line1 address-line1)
(city address-city)
(country address-country))
(define-record-type user
(make-user id name addr)
user?
(id user-id)
(name user-name)
(addr user-addr))
(define u
(make-user 123 "Ada"
(make-address "12 St James" "London" "UK")))
(user-name u) ; => "Ada"
(address-city (user-addr u)) ; => "London"Pattern Matching with Records
Records don't have a dedicated pattern form, but you can use binding patterns with when guards:
(define (describe v)
(match v
(p when (point? p)
(string/append "point("
(number/to-string (point-x p))
", "
(number/to-string (point-y p))
")"))
(_ "not a point")))
(describe (make-point 3 4)) ; => "point(3, 4)"
(describe {:x 3 :y 4}) ; => "not a point"You can also match on type:
(define (record-type-name v)
(match (type v)
(:point "a point")
(:person "a person")
(_ "something else")))Domain Modeling Example
Use records to represent values that have been validated:
(define-record-type email
(make-email value)
email?
(value email-value))
(define (parse-email s)
(if (regex/match? #".+@.+\..+" s)
(make-email s)
(error "invalid email")))
(define e (parse-email "ada@example.com"))
(email? e) ; => #t
(email-value e) ; => "ada@example.com"Multiple Record Types
(define-record-type color
(make-color r g b)
color?
(r color-r)
(g color-g)
(b color-b))
(define-record-type person
(make-person name age)
person?
(name person-name)
(age person-age))
(define red (make-color 255 0 0))
(define ada (make-person "Ada" 36))
(color? red) ; => #t
(person? ada) ; => #t
(color? ada) ; => #f
(color-r red) ; => 255
(person-name ada) ; => "Ada"
(type red) ; => :color
(type ada) ; => :personSerialization
Records are not JSON-encodable directly. If you need to serialize a record, convert it to a map first:
(define (point->map p)
{:x (point-x p) :y (point-y p)})
(json/encode (point->map (make-point 1 2)))
; => "{\"x\":1,\"y\":2}"Similarly, when loading data from JSON or the KV store, convert maps to records after parsing.
Tips & Edge Cases
- Accessor type-checking: calling
point-xon a non-point value errors - Type tag: the tag returned by
typeis derived from the record type name —point→:point - No generic field access: you can't use
getor keyword-as-function on records — use the generated accessors