given:
(defrecord point [^long x ^long y])
defrecord does a couple of things, with everything living on top of deftype and the host system (probably the jvmâŚ). On the jvm/clr, defrecord is a macro that defines a map container using deftype to implement all the clojure interfaces necessary for persistentmap semantics, and any user-supplied protocols (that arenât already supplied by defrecord for the persistent map implementation). The macro also defines convenience functions for constructing the record from positional arguments and from generic maps.
deftype ends up emitting a class (havenât looked deeply at cljs, your mileage may vary) with potentially typed positional constructors, although the direct constructor (e.g. for the newly defined and loaded class user.point) will have - as per deftype - a constructor albeit with extra fields that the defrecord macro added behind the scenes:
[x y __meta __extmap __hash __hasheq]
The underscored fields are used for implementing/carrying metadata, a map of non-static entries (entries that are not the fields x or y, corresponding to :x or :y in the record), and hash codes for clojureâs equality semantics (typically cached once computed). This is hidden from the caller though, but they exist in the deftype invocation. So the corresponding class user.point actually has a constructor of 6 args if we invoke it directly (I think there are overloads provided to allow just the 2-arg positional field-based constructor as well though). Since we provided primitive type hints on the fields x and y, they are checked in the constructor too, and the provided functions give us vars we can refer to for convenience (and readability) instead of using/importing the class all the time:
user=> (user.point. 1 2 nil nil 0 0)
#user.point{:x 1, :y 2}
user=> (user.point. 1 2)
#user.point{:x 1, :y 2}
user=> (user.point. :a :b)
Execution error (ClassCastException) at user/eval229 (REPL:1).
clojure.lang.Keyword cannot be cast to java.lang.Number
user=> (->point 1 2)
#user.point{:x 1, :y 2}
user=> (->point :a :b)
Execution error (ClassCastException) at user/eval178$->point (REPL:1).
clojure.lang.Keyword cannot be cast to java.lang.Number
Type information will also be propagated on static fields if we access them directly (as opposed to the generic map-access that is also supported):
user=> (let [^point p (->point 1 2) x (get p :x)] (+ x 2))
Boxed math warning, NO_SOURCE_PATH:1:44 - call: public static java.lang.Number clojure.lang.Numbers.unchecked_add(java.lang.Object,long).
3
user=> (let [^point p (->point 1 2) x (.x p)] (+ x 2))
3
So there is some limited albeit useful typing provided at the record level. You get constructors that can implicitly act as a validation layer if and only if you use primitive type hints. For class type hints, all bets are off since things are stored as objects and merely hinted/claimed to be what they are. The following example happily passes through the type system:
user=> (defrecord blah [^String x ^String y])
user.blah
user=> (->blah 2 3)
#user.blah{:x 2, :y 3}
So non-primitive fields on records donât buy you much on the structural validation front. They can help with supply type hints for subsequent operations on fields though (as long as the hints are correct) to cut off reflection calls.
Given that, we have your observation:
The way i see defrecord is that there are a set of keys declared but not enforced strictly.
The associative semantics for records are similar to maps. You can assoc values into them. Operationally, there is a check in the implementation to see if the key being assocâd corresponds to a static field on the record, and if so a new record is instantiated with that field value; otherwise the __extmap field is a hidden map of the non-static or external entries and it becomes the target of associations. Given that operations may flow through the recordâs constructor, if there are primitive typed fields that end up as the target of an assoc, then you may get some type checking transitively:
user=> (def p (->point 1 2))
#'user/p
user=> (assoc p :x 4)
#user.point{:x 4, :y 2}
user=> (assoc p :x :a)
Execution error (ClassCastException) at user.point/assoc (REPL:1).
clojure.lang.Keyword cannot be cast to java.lang.Number
dissoc works similarly, except if we dissoc a key corresponding to a static field, the implementation coerces the result into a persistent map since the record contract no longer holds:
user=> (type (dissoc p :x))
clojure.lang.PersistentArrayMap
Non-static fields are redirected to the __extmap as with assoc.
So we can freely assoc within the record contract and maintain any primitive type information and some layer of validation, and we can freely dissoc any non-static fields. If we dissoc a static field, all the goodies from the record (fast static field access, inline/custom protocol implementations, custom type, type hints on fields, ordered static field printing) are ditched in the resulting coerced persistent map. So records can âsilentlyâ downgrade based on the semantics of dissoc (caller beware).
I would say the primary benefits of records are efficient access to static fields, primitive/typed fields, support of generic map operations, extensible keys, some IMO weak validation, and inline protocol implementations (one common example is custom clojure.lang.IFn implementation to make the record invokable, it is not by default, this shows up in some AST implementations). They have a nice mix of the efficiencies of objects with name/static fields, while retaining map semantics and extension.
With spec, it is more about strictness and less about readability at a quick glance.
Spec is pretty flexible IMO, particularly with all the regex-inspired matching you can do on data. I think the community precursor (still in use) schema focused on declarative first, and data specs bridged that gap quite a bit to enable simple declarative map specs.
Whatâs really nice about spec and peers though, is the ability to build up fairly complicated specifications on the structure of the data as opposed to relatively simplistic type information. You have have arbitrary predicates (although itâs typically more useful to use the structure-based primitive specs they provide like sets) and compose them in many ways. It feels more like dependent typing to me, or programmable contracts. The other benefits (generating random data for property based testing) are slick as well. Malli looks really excellent and probably exceeds spec/spec2 in capability from what I have seen; the only downside is itâs not bundled as part of clojure.