To-primitives GitHub issue

vibecode
{"vibecode": {
    "doc": "to_primitives",
    "role": "spec for `.to_primitives` — the method every serializable Caspian object must implement, returning a value composed entirely of JSON primitives (hashes, arrays, strings, numbers, booleans, null). The canonical hook used by serializers anywhere a value needs to cross a process boundary, write to disk, or ship as JSON.",
    "status": "early spec — core rule settled, edges still being shaped",
    "audience": "Caspian programmers writing classes whose instances may be serialized; serializer implementers"
}}

Caspian provides a uniform way for any value to convert itself into a form suitable for JSON serialization: the to_primitives method. Every object that can meaningfully be serialized must implement it.


Core rule GitHub issue

Every serializable object has a to_primitives method. Calling it returns a value composed entirely of JSON primitives — recursively. Nothing in the returned structure is a Caspian object that itself needs further conversion; everything is already in a shape JSON's encode step can consume directly.

The valid JSON primitives are:

A value returned by to_primitives that contains anything else (a Caspian object, a class reference, a function, etc.) is an error in the implementation.


How it's called GitHub issue

$foo.to_primitives

Direct method on the object, not under .object. This is provisional$foo.object.to_primitives was the other candidate (placing it under the engine-protocol namespace alongside .object.classes, .object.freeze, etc.). The decision to land at $foo.to_primitives is "let's see how it shakes out"; if naming collisions or convention drift surface, the API may move under .object later.


Why "primitives" and not "JSON" GitHub issue

The method returns a Caspian value composed of primitives, not a JSON-encoded string. A separate step (the JSON encoder, or whatever target format's encoder) converts the primitive tree into bytes.

This separation matters:


The conversion chain GitHub issue

to_primitives sits at the bottom of a default conversion chain that makes "just print this value" work seamlessly for the common case:

to_string → to_json → to_primitives

For most objects, the default to_string delegates to to_json, which in turn calls to_primitives and JSON-encodes the result. So this code works without any explicit serialization step:

caspian
puts {foo: 'bar'}

The chain: puts calls .to_string on its argument → for a hash, .to_string defaults to .to_json.to_json calls .to_primitives (a plain hash returns itself; it's already primitives) → .to_json encodes to '{"foo":"bar"}'puts writes that string to STDOUT.

The same chain applies to any object whose class doesn't override to_string. A class that wants a different string representation (a human-readable summary, a debug form, etc.) overrides to_string directly. A class that only wants to customize the JSON shape (without changing what its to_string returns) overrides just to_primitives and inherits the rest of the chain.

This is why O'Brien workers can write Xeme JSON to STDOUT with a plain puts {...} — the hash literal stringifies to JSON automatically because nothing in the chain has been overridden.

The chain ends at to_primitives because that's where the value-to-primitives conversion actually happens. Everything above it (to_json, to_string) is mechanical: encode the primitives, return the string. Classes that customize serialization customize to_primitives; classes that customize string representation customize to_string. The split keeps the responsibilities separate.


Recursive composition GitHub issue

Most classes get their to_primitives implementation by composition: walk the object's data and call to_primitives on any nested values, assembling the results into a hash or array of primitives.

Engine support for the default case (auto-walking the bucket and stack) is open — see Open questions. The expectation is that simple classes don't have to implement to_primitives explicitly; the engine handles the common case and the class only overrides for non-trivial state (private platter buckets, redacted fields, custom shapes, etc.).


Open questions GitHub issue


© 2026 Puck.uno