Supported types

August 3, 2026 · View on GitHub

Semantic equality (=) is preserved for every listed type. Concrete JVM class may differ — hako roundtrips value semantics, not class-identity.

Scalars

ClojureJavaWire
nilnullspecial 0xF0
true / falseBoolean.TRUE / FALSEspecial 0xF1 / 0xF2
\aCharacterspecial 0xF8 + u16 code unit
3.14Doublefloat 0x21 + f64 LE
3.14MFloatfloat 0x20 + f32 LE
42Long (and cross-type Integer / Short / Byte)uint / sint tier + tier payload

Integers use a size-tier scheme (inline 0..11, u8, u16, u32, u64) for uints, and zig-zag + same tiers for negatives. Integer, Short, Byte all decode as Long — the wire format has one integer path.

Special float values:

ValueWire
Double/NaNspecial 0xF3
Double/POSITIVE_INFINITYspecial 0xF4
Double/NEGATIVE_INFINITYspecial 0xF5

Strings and bytes

ClojureWire
"hello"string major 0x4 + UTF-8 bytes
byte[]bytes major 0x3 + raw bytes

Strings are stored as UTF-8. Decoding to a Clojure String is unavoidable (JDK's new String(bytes, UTF_8) handles both LATIN1 compact strings and full UTF-8).

With {:zero-copy true}, byte[] payloads decode to MemorySegment slices instead of fresh byte[]. See Arenas.

Identifiers

ClojureWire
:fookeyword major 0x5 + <ns-length:u8><ns bytes><name bytes>
:ns/foosame, with ns = "ns"
'foo, 'ns/foosymbol major 0x6, same layout

First occurrence in a message is emitted inline; subsequent occurrences are one-byte symrefs (major 0xC) that point into the per-message symbol table.

Keyword and Symbol use distinct symbol-table slots even if they have the same name (:foo and 'foo do not collide — this was a latent bug fixed in 0.1.0).

Numerics

ClojureWire
BigIntegerbignumeric major 0xD0 — 2's-complement BE bytes
clojure.lang.BigIntsame wire; decoded as BigInt
BigDecimalbignumeric 0xD1 — i32 scale + BigInt unscaled
Ratiobignumeric 0xD2 — num + den BigInts

Precision is exact.

Time and identity

Clojure / JDKWire
java.util.UUIDspecial 0xF6 + 16 bytes
java.time.Instantspecial 0xF7 + i64 epoch-sec + i32 nanos
java.util.Datespecial 0xF9 + i64 epoch-millis
java.time.Durationext 0xE0 13 + i64 secs + i32 nanos
java.time.Periodext 0xE0 14 + 3 × i32 (y, m, d)
java.time.LocalDateext 0xE0 15 + i64 epoch-day
java.time.LocalTimeext 0xE0 16 + i64 nano-of-day
java.time.LocalDateTimeext 0xE0 17 + i64 epoch-day + i64 nano-of-day
java.time.ZonedDateTimeext 0xE0 18 + instant + zone-id string
java.time.OffsetDateTimeext 0xE0 19 + local-date-time + i32 offset-sec
java.util.regex.Patternext 0xE0 11 + source string + i32 flags
java.net.URIext 0xE0 12 + string

Instants roundtrip pre-1970 (negative epoch-sec) correctly. Pattern flags (case-insensitive, multiline, …) are preserved. ZonedDateTime preserves the exact instant, including DST-overlap local times.

Collections

ClojureWire
[], [1 2 3]vector major 0x7 + count + elements
'(1 2 3)list major 0x8 + count + elements
#{1 2 3}set major 0x9 + count + elements
{:a 1 :b 2}map major 0xA + count + (k, v) pairs

Unknown-length sequences (lazy seqs, non-Collection iterables) are emitted as indefinite-length lists: tier nibble 15 + elements + break tag 0xFA (see SPEC §3.5). Laziness is preserved on encode; they decode as PersistentList.

Set / map iteration order on the wire is undefined. Decoded values compare semantically (=) with the source but may not iterate in the same order.

Sorted collections

ClojureWire
(sorted-set 3 1 2)ext 0xE0 00 + count + values
(sorted-map ...)ext 0xE0 01 + count + (k, v) pairs

Only the default comparator (compare) roundtrips. Custom comparators throw at encode unless {:coerce-custom-comparator true} is set, in which case the comparator is silently dropped (falls back to compare on decode).

Queues

ClojureWire
clojure.lang.PersistentQueue/EMPTYext 0xE0 02 + count + values

Decodes as PersistentQueue.

Arrays

JavaWire
long[]ext 0xE0 05 — packed i64 LE
double[]ext 0xE0 06 — packed f64 LE
int[]ext 0xE0 07 — packed i32 LE
float[]ext 0xE0 08 — packed f32 LE
short[]ext 0xE0 09 — packed i16 LE
char[]ext 0xE0 0A — packed u16 LE
boolean[]ext 0xE0 0B — one byte per element
Object[]ext 0xE0 10 — count + N encoded values

Primitive arrays preserve component type. Any reference-typed array (Object[], String[], …) encodes via the object-array subtype and decodes as Object[] — component type is not preserved.

With {:pack-homogeneous true} on encode, vectors of all-Long or all-Double elements are auto-detected and emitted as packed prim arrays. See Performance.

Records

Both Clojure defrecords and Java records require explicit registration. See Extensions §Records.

Wire: ext 0xE0 03 + symref classname + field count + values.

Fallback for unknown types

If none of the above match at encode time:

  1. IPersistentVector / IPersistentMap / IPersistentSet fall into the container path.
  2. ISeq / Iterable materialize and encode as a list.
  3. User-tag registry lookup by (class v) — if found, invokes the user's write callback.
  4. Otherwise: IllegalArgumentException "no writer for value of type X".

Concrete-type contract

The wire format encodes value shape, not concrete class. The decoder picks a reasonable concrete class per the container's size and content, adapting to the runtime Clojure version.

Maps

PersistentArrayMap vs PersistentHashMap selection is based on runtime-probed thresholds:

  • Clojure ≤ 1.12: array-map cap ≈ 8 for all key types.
  • Clojure 1.13+: array-map cap = 8 for mixed keys, 64 for all-keyword keys.

hako probes both thresholds at ns-load and adapts. Encoded bytes from any Clojure version decode on any other Clojure version; = holds; only the runtime class may differ.

Sets

Similar strategy — hash-set by default; sorted-set extension for the sorted case.

Lists vs seqs

An ISeq (including lazy seqs) encodes as a list. On decode it materializes to a PersistentList. If you need lazy semantics on the far side, apply seq yourself.