Extensions
July 30, 2026 · View on GitHub
hako's core wire format covers Clojure's built-in types. Everything else — records, application-specific value types, third-party serializers — plugs in via one of two registries.
Records
Both Clojure defrecords and Java records (JEP 395) are supported.
Both need explicit registration — hako will not encode an unknown
record class.
Clojure defrecord
(require '[s-exp.hako :as hako]
'[s-exp.hako.ext :as ext])
(defrecord Point [x y])
(ext/register-record! Point)
(hako/decode (hako/encode (->Point 3 4)))
;; => #user.Point{:x 3, :y 4}
The registration reflects on Point once — reads the basis field
order via getBasis, resolves the canonical positional constructor,
and caches a MethodHandle with all args adapted to Object (so
narrow-to-primitive coercions like Long → int work at the
invoke).
Java records
public record Point(int x, int y) {}
(import '(com.example Point))
(ext/register-record! Point)
(-> (Point. 3 4) hako/encode hako/decode .x)
;; => 3
Java records use RecordComponent.getAccessor() per field. hako
caches a MethodHandle per accessor at registration time, so the
write path invokes them directly with no reflection.
How registration works
register-record! builds a RecordInfo (a Java record in
com.s_exp.hako) and stores it in the JVM-global
RecordRegistry (Java ConcurrentHashMap<String, RecordInfo> +
ConcurrentHashMap<Class<?>, RecordInfo>).
Encode path (in Writer.writeRecord):
RecordRegistry.byClass(v.getClass())— one hash lookup.- Emit tag
0xE3(extension: record). writeInternedthe classname (dedups on repeat via sym-table).- Emit field count.
- For Clojure defrecord:
IPersistentMap.valAt(kw)per field keyword. - For Java record:
accessor MethodHandle.invokeWithArguments(v)per field.
Decode path (in Reader.readRecord):
- Read classname (symref-decoded via sym-table).
RecordRegistry.byName(classname).- Verify field count matches.
- Read N values, materialize an
Object[]. ctorMH.invokeWithArguments(args)— returns the record instance.
Registration is safe for concurrent access. Re-registering the same class is idempotent (same class → same RecordInfo).
Failure modes:
- Encode: unregistered class →
IllegalStateException: hako: record class not registered: <name>. - Decode: unregistered classname →
IllegalStateException: hako: unknown record class: <name>. - Field-count mismatch — e.g. the class was re-defined with a different field set — throws with expected vs actual.
User-tagged types
For value types that don't fit built-in types, don't extend
IRecord, or that you don't own (e.g. java.net.URI,
java.time.LocalDate), use user-tags.
Registration
(import '(java.net URI))
(ext/register-user-tag!
1 ; app-local id (shifted to 0x10000001 on wire)
URI ; class to dispatch on
(fn write [w u] ; 2-arity: writer, value
(.writeString w (str u)))
(fn read [r] ; 1-arity: reader
(let [tag (.getByte r)
low (bit-and tag 0x0F)
n (.readTierPayload r (int low))]
(URI. (.getString r (int n))))))
Now URI instances encode as user-tag frames, and decode via the
registered read fn. The clj-kondo hook bundled in the jar validates
the write-fn / read-fn arities at edit time.
Write callback contract
Signature: (fn [^Writer w value]).
- Do not emit the frame envelope (
0xEF+ tag id + length prefix). hako wraps your payload — you emit only the payload bytes. - You may use any Writer method:
writeString,writeLong,putBytes, container helpers, or recursively.writeAny w child-value. - Payload size limit: − 1 bytes (u32 length prefix).
Read callback contract
Signature: (fn [^Reader r]).
- You start at the first byte of the payload. The framework has
already consumed the
0xEFtag byte, the u32 id, and the u32 length prefix. - You must consume exactly
lengthbytes (matching what the writer emitted). hako verifies this and throws if you under/over read. - You may call any Reader method or recursively
.readAny r.
Tolerant decode
If a message references a user-tag id that isn't registered in the
current JVM, decode throws by default. With {:tolerate-unknown-tags true},
it returns a TaggedValue:
(hako/decode bs {:tolerate-unknown-tags true})
;; => #s_exp.hako.ext.TaggedValue{:ext 0x10000001
;; :bytes #<MemorySegment ...>}
:ext is the u32 id; :bytes is a MemorySegment slice of the raw
payload bytes. Use this when you might receive messages from
newer producers that carry types your consumer doesn't yet know
about.
Payload bytes stay valid until the source segment closes; see Arenas.
ID ranges
Reserved u32 wire space (from ../EXTENSIONS.md §E.2):
| Range | Purpose |
|---|---|
0x00000000 – 0x0000FFFF | Reserved for hako core. |
0x00010000 – 0x0FFFFFFF | Public third-party (PR to hako). |
0x10000000 – 0xFFFFFFFF | Private / application-defined. |
register-user-tag! accepts a small app-local id (0..0x0FFFFFFF)
and shifts it into the private range — callers write 1, 2, ...
without picking hex constants. For cross-app coordination on a full
u32 wire id, use register-user-tag-raw!.
Metadata
Metadata is off by default because most workloads don't need it and
the wrapper costs a byte per IObj. Opt in:
(hako/encode (with-meta [1 2 3] {:tag :vec})
{:preserve-meta true})
On the wire, this emits extension subtype 0xE4 (with-meta),
followed by the inner value and then the meta map. Decoders that
don't want metadata don't need a matching flag — the wrapper is
unconditional on the wire; the reader always reapplies
with-meta when it sees the tag.
Extension subtype registry
Canonical byte-level definitions live in ../EXTENSIONS.md. Key points from a user perspective:
- Subtypes
0..8are built-in (sorted-set, sorted-map, queue, record, with-meta, prim-longs, prim-doubles, prim-ints, prim-floats). - Subtype
15is the extensibility path — length-prefixed, safe to skip. Registered viaext/register-user-tag!. - Unknown built-in low-nibble values (
9..14) throw on strict decode. Those are spec bugs, not schema drift —:tolerate-unknown-tagsdoes not apply.