What Traits Give, Without the Traits

Every dogfood application hand-wrote the same boilerplate: how to print a value, how to compare two of them, how to turn a record into JSON and back. That boilerplate is exactly what a trait or typeclass system exists to eliminate — but building one means resolution, coherence, dictionary passing, a whole apparatus at odds with a language that prizes being explicit. Mere took a narrower road. It already had one polymorphic builtin that dispatched by type with no trait anywhere, resolved entirely at compile time, and it grew a family from that seed instead of building a system. The most interesting member of that family wasn't designed at all — a dogfood taking hostile input forced its exact shape.

merepolymorphismjsonderivedogfoodlanguage-design

There is a kind of code that every one of the dogfood applications wrote by hand, and wrote again, and hated writing. How to render a value as text for a log line. How to decide whether two values are equal. How to turn a record into JSON and, worse, how to turn JSON back into a record, field by field, with a mismatch on every rename. The database library was thick with hand-written encoders and decoders. This boilerplate is not incidental; it is precisely the thing that trait systems and typeclasses were invented to abolish. So the pressure to build one had been mounting for a while, and it was time to answer it.

The system you don’t want to build

The trouble is that a trait system is a large and opinionated machine. It needs a way to resolve which implementation applies at each use, rules about coherence so that two imports can’t disagree about how a type prints, and usually a runtime mechanism — dictionaries passed invisibly alongside values — to carry the chosen implementation to where it runs. All of that is doable, and all of it pulls against the one thing the language had been unwilling to give up: that what happens at runtime should be legible from the source, with no invisible arguments threaded behind your back. An earlier paper study of traits had reached exactly this conclusion and filed the whole idea under defer until a dogfood makes the pain unavoidable. The pain had now arrived. The question was whether the full machine had to arrive with it.

The seed that was already there

It didn’t, because the language already contained a smaller answer to the same problem, hiding in plain sight. The builtin that renders any value as text was already polymorphic — you could apply it to an integer, a list, a record, a variant — and yet there was no trait for it anywhere in the source, no instance declarations, no dictionary. It worked because the type inferencer already knew the concrete type at every place it was used, and the code generator simply emitted the right rendering for that specific type, inlined, at compile time. One name, many type-specific behaviours, resolved statically and made explicit in the generated code. That is most of what a trait gives you, achieved without any of the trait machinery — and it had been sitting in the language the whole time as an ordinary builtin.

So the move was not to build a system but to grow a family from that seed. If a value could be rendered as text by compile-time specialisation, it could be rendered as JSON the same way; a structural JSON encoder joined the family, turning a record into an object by dropping its type name, a list into an array, an option into either its contents or null. Structural equality joined it too, comparing two values of a type by walking their shape. Each new member reused the existing specialisation path rather than introducing a new mechanism, which is the discipline the whole series runs on: when a new problem appears, extend the machinery already built before inventing more.

The one honest asymmetry

The encoder had a natural partner — a decoder, JSON text back into a typed value — and building it surfaced a genuine asymmetry worth being honest about. The encoder can look at the value it was handed and dispatch on that value’s type. The decoder cannot: the thing it is handed is a string, and a string is a string regardless of what you hope to get out of it. So the decoder takes its target type not from its argument but from the call site, from the annotation that says what you are decoding into. Given that type, it does the structural work in reverse — a JSON object becomes a record by matching field names, an array becomes a list or a tuple, a value or null becomes an option, a tagged form becomes a variant. It is the mirror of the encoder in every respect but the one place where the information has to come from a different direction, and saying so plainly is better than pretending the symmetry is perfect.

The member the dogfood designed

The most instructive member of the family was never designed on paper. The decoder, as first built, was fail-fast: hand it malformed input and it aborts — an exception in the interpreter, and in a native binary, a process that exits. That is the right behaviour for trusted input: a configuration file, an internal data file, a test fixture, where malformed input is a bug you want to hear about loudly. Then the blog application tried to use it on the one thing it must never trust — the body of an incoming web request. A fail-fast decoder on hostile input doesn’t report a bug; it takes the whole server down with a single malformed request. That is a security regression, not a convenience.

So a second decoder was forced into existence, one that returns an optional value — nothing on failure — instead of aborting. It was not on any roadmap; the dogfood demanded it by being a real server exposed to a real network. And it drew a clean line that is now part of the language’s advice: the aborting decoder is for input you trust, the optional one is for input you don’t. The blog’s signup and login and posting were rewritten around typed request records fed through the optional decoder, and the fix was verified where it mattered — a valid body produces a created resource, a malformed one produces a client error, and, most importantly, the server is still alive afterward to serve the next request correctly. The loop closed against a real database, in a native binary, under exactly the conditions that had exposed the gap.

The edges, stated plainly

A family grown rather than designed has edges, and the useful thing is to name them rather than hide them. Encoding an option as a bare value or null is idiomatic and round-trips cleanly, but it cannot distinguish an absent value from a present one that happens to be empty — a nested-option corner that is rare in practice and real all the same. The decoder requires every field of a record to be present, so genuinely optional keys are not yet expressible and are filed as a future finding. And the family did not land on every backend at once: the decoder went to the interpreter and the native path first, which briefly broke the browser deployment of the very application that motivated it, and the WebAssembly version followed in the next release to pay that debt back and bring the backends level. None of these is hidden in the way the feature is described, because a guarantee is only worth what its stated limits make it worth.

What the whole arc bought was the convenience a trait system sells — no more hand-written printers, comparators, encoders, decoders — without the trait system itself: no resolution, no coherence rules, no dictionaries, nothing threaded invisibly through the program. It came from noticing that one existing builtin had already solved the hard part, and growing a family in its image; and its sharpest feature came not from a designer’s foresight but from a dogfood that stood in the open and got hit.

← Back to Mere: Building a Language