A Clojure record type with a fixed key set.
A Clojure record is an open map:
assoc of any key succeeds. A key with a typo becomes a new entry.dissoc of a declared key changes the record into a plain map.These operations do not fail. Key typos stay hidden until a later failure, often a nil value far from the cause.
A Clojure record is also an identity object. Two records with equal fields are different objects. The JVM cannot store a record inline in an array or in another object, and a field cannot reject nil.
defclosedrecord defines a record type with a fixed key set. It has
the same syntax as defrecord. An operation that changes the key set
throws ex-info at the location of the change:
assoc (also conj, merge, into, update) of an undeclared key.dissoc of a declared key.empty.map->Name with an undeclared key.All other operations behave as with defrecord: field access,
assoc of a declared key, protocols, interfaces, type hints,
metadata, equality, hashing, printing and reader literals. Call sites
that use a record correctly do not change. Reads and declared-key assoc
have similar costs in the measured three-field cases. Wider records can differ;
see performance measurements and limits.
Reads are not checked. (:nmae r) returns nil, as with defrecord.
The Clojure 1.13 destructuring directives :keys!, :select and
:all find read errors. A closed record is a complement to these
directives.
A closed record is for use as an internal type. It does not validate values. Use malli or spec for that.
The option :value-type true defines the record through
value-type. On a JVM with
JEP 401 value classes, the record is a value object: it has no
identity, and ^:non-nil fields reject nil. The closed key set does
not change. A value-type record has no metadata. Records without the
option do not load value-type.
AI coding agents wrote most of the code, tests and documentation of this project, under the direction of a human developer.
Released on Clojars. The API is small: defclosedrecord and check.
The API changes only by addition. See the changelog.
:value-type true is experimental and is not released. It depends on
value-type, which is experimental, and on a Valhalla early-access JDK
for value classes. Its behaviour can change.
The library is on Clojars.
Clojure CLI (deps.edn):
{:deps {org.hugoduncan/closed-record {:mvn/version "0.1.190"}}}
Leiningen (project.clj):
[org.hugoduncan/closed-record "0.1.190"]
:value-type true needs Clojure 1.12.6 and value-type 0.1.127 or later
on the class path, at macro expansion and at run time. value-type is on
Clojars:
{:deps {org.hugoduncan/value-type {:mvn/version "0.1.127"}}}
For value classes, start the JVM from a Valhalla early-access JDK with
--enable-preview. value-type.array also needs
--add-exports=java.base/jdk.internal.value=ALL-UNNAMED. On other
JVMs, value-type's fallback policy (system property
value-type.fallback) selects an identity class, a warning, or an
error. The system property value-type.value-classes (auto or
off) is next to it: off selects identity fallback, also on a
Valhalla JVM.
(require '[closed-record.core :refer [defclosedrecord check]])
(defclosedrecord Point [x y])
(def p (->Point 1 2))
(:x p) ;=> 1
(assoc p :x 10) ;=> #user.Point{:x 10, :y 2}
(dissoc p :z) ;=> #user.Point{:x 1, :y 2}
(assoc p :z 3)
;; throws: Undeclared key :z for closed record user.Point
;; ex-data: {:type :closed-record/undeclared-key, :record user.Point, :key :z}
(dissoc p :x)
;; throws: Cannot dissoc declared key :x from closed record user.Point
;; ex-data: {:type :closed-record/declared-key-dissoc, ...}
(map->Point {:x 1 :y 2 :z 3})
;; throws: ex-data {:type :closed-record/extra-keys, :keys #{:z}, ...}
(map->Point {:x 1}) ;=> #user.Point{:x 1, :y nil}
To get a plain map, use (into {} p) or select-keys.
A reader literal with an undeclared key does not throw. The extra key
is hidden: lookup does not find it, and it does not change equality or
hash. Use check to find it:
(def q #user.Point{:x 1 :y 2 :z 3})
(:z q) ;=> nil
(check p) ;=> p
(check q)
;; throws: ex-data {:type :closed-record/extra-keys, :keys #{:z}, ...}
check also throws (:closed-record/not-closed-record) for a value
that is not a closed record.
See the API reference for arguments, options, constructors, and error contracts.
(defclosedrecord Point [^long x ^long y] :value-type true)
(defclosedrecord Segment [^Point ^:non-nil start
^Point ^:non-nil end]
:value-type true)
(identical? (->Point 1 2) (->Point 1 2)) ;=> true on a value-class JVM
(def s (->Segment (->Point 1 2) (->Point 3 4)))
(assoc s :start nil) ;; throws NullPointerException
(assoc s :z 1) ;; throws :closed-record/undeclared-key, as before
The record contract does not change, with these differences:
identical? compares field values. locking and weak references
throw IdentityException.hash call computes the hash again.meta gives nil, and with-meta throws
ClassCastException.check to find.__meta or __extmap fails at definition
on value output. On identity fallback it reads nil.On identity fallback, identical?, locking and weak references work
as with defrecord.
^:non-nil field needs a tag that names a concrete, non-primitive
class.NullPointerException, and a value of the wrong type gives
ClassCastException. This applies on value output and on identity
fallback.:value-type true, :non-nil has no effect, as in
defrecord.value-type.array makes arrays that reject nil elements:
(require '[value-type.array :as va])
(def points (va/into-array Point [(->Point 1 2) (->Point 3 4)]))
(aset points 0 nil) ;; throws NullPointerException
The JVM can store value objects inline in an array or in another
object. This is flattening. Flattening is a JVM choice, not a
guarantee. va/array-info gives the actual layout in :flat?. On
Valhalla 27-jep401ea3+1-1, a value record has only its declared
fields, and a record with at most 64 bits of fields can be flat:
(defclosedrecord Cell [^long v] :value-type true)
(:flat? (va/array-info (va/into-array Cell [(->Cell 1) (->Cell 2)])))
;=> true on Valhalla 27-jep401ea3+1-1
Point [^long x ^long y] (128 bits) is not flat on that JVM.
See value-type records for the full behaviour, field rules, errors and requirements.
A malli [:map ...] schema validates and explains a closed record as
a map. Some transformers add an undeclared key or remove a declared
key. On a closed record, these throw:
default-value-transformer with a default for an undeclared key
throws :closed-record/undeclared-key.strip-extra-keys-transformer with a schema that does not have all
the declared keys throws :closed-record/declared-key-dissoc.key-transformer throws :closed-record/empty.To use these transformers, decode a plain map and keep the result as a plain map:
(m/decode schema (into {} r) transformer)
Call map->Name on the result only when the result has all the
declared keys and no other keys. An undeclared key throws. A missing
declared key gives nil.
See development instructions for tests, CI, benchmarks, and source JAR builds.
See release instructions for local preparation, inspection, a separate push, manual preview and publication, credentials, and recovery after partial failure.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |