Tzopilotl
Docs
GitHub

Tzopilotl by Example

26. Serialization and Pretty Printing

Values print, compare, hash, and serialize as graphs: shared substructure is respected, and cycles — built through Ref assignment or in-place container mutation — are handled everywhere instead of recursing forever (see also §17).

26.1 Pretty Printing

prettyString(x) renders a value against a target line width (default 80; pass a second Int argument to change it), and prettyPrint(x) prints the result. Each container stays on one line when it fits and otherwise breaks one element per line with two-space indentation. At a very large width the output equals toString. The REPL and notebook cells display results this way automatically.

let v = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];
v prettyPrint;               -- fits: [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
v prettyPrint(20);           -- narrower target width:
-- [
--   [1, 2, 3],
--   [4, 5, 6],
--   [7, 8, 9]
-- ]

struct Point { x Float, y Float }
Point { x: 1.5, y: 2.5 } prettyPrint(12);
-- Point {
--   x: 1.5,
--   y: 2.5
-- }

Cyclic values print with ^n^ markers (“the same object n container levels up”) in both the flat and pretty printers — printing always terminates.

26.2 serialize and deserialize<T>

serialize(x) encodes any data value into a compact binary Bytes buffer; deserialize<T>(b) decodes it, with the target type as an explicit type argument. All the data types serialize — scalars, strings, Bytes, arrays, lists, maps, sets, refs, tuples, structs, enums (including recursive ones), and persistent collections. Types containing functions, coroutines, futures, actors, Any, or existentials are rejected at compile time.

struct Patch { name String, params [Float] }
let p = Patch { name: "lead", params: [440.0, 0.3] };

let b = serialize(p);                      -- Bytes
let q = deserialize<Patch>(b);
println(p == q);                           -- true

Serialization is graph-preserving: shared substructure is written once and restored as sharing; cycles are stored as references and round-trip intact. And it is deterministic: equal values produce identical bytes no matter how they were built (map and set entries are written in a canonical order), so hash(serialize(x)) is a stable content address.

-- Aliases stay aliases across a round trip
let shared = &10;
let out = deserialize<[Ref<Int>]>(serialize([shared, shared]));
out[0] <- 99;
*(out[1]) println;                         -- 99  (still one Ref)

-- Cycles round-trip and stay cyclic
enum Tree { node [Tree], leaf Int }
var a = [Tree.leaf(1)];
a push!(Tree.node(a));
let c = deserialize<[Tree]>(serialize(a));
println(c == a);                           -- true

-- Deterministic bytes: construction order doesn't matter
var m1 = ["a": 1]; m1["b"] = 2;
var m2 = ["b": 2]; m2["a"] = 1;
println(serialize(m1) == serialize(m2));   -- true

26.3 Type Safety and Errors

The buffer embeds a structural signature of the type it was serialized as — field and case names and the full recursive layout. deserialize<T> validates it against T by exact comparison, so decoding into the wrong type, or feeding truncated/corrupt bytes, raises a clean runtime error rather than producing garbage. Compatibility is structural: a structurally identical declaration (same shape and the same spelled names) in another program round-trips; renaming a field or case intentionally breaks it. Declared sizes are validated against the buffer length, so hostile input cannot cause runaway allocation.

let b = serialize(42);
-- deserialize<Float>(b);      -- runtime error: type signature mismatch

Explicit type arguments

deserialize<T>(b) uses the explicit call-site type argument form f<T>(…). It is available on built-ins that need a type the arguments can't supply — typeName<T>() String is another (typeName<[Int]>()"[Int]"). Ordinary template functions infer their type parameters from arguments as usual (§20).