Case study #0003

Every state is drawn, and the match is exhaustive

by Vidal Vasconcelos

A screen cannot stand in a state it has not drawn. The kernel cuts the unions, a leaf renders each member, the wrapper is nothing but an exhaustive match, and an absence is a value rather than a null.

← Back to the studio

Context

The last thing on the studio's own page is a row that takes an address for a letter. It can be in three states. It is asking, with a field and a button. It has kept the address, and says so. Or the address did not get anywhere, and it says that instead, with the field still there because it is still asking. Three states, and the row is a different whole thing in each of them, a different mark, a different line, a form or no form. Nothing about that is unusual. What is unusual is that the row cannot be in a fourth state, and cannot be in none, and the compiler is what says so.

Most screens keep a state quiet. A loading flag sits beside an error string beside a piece of data, and three booleans make eight combinations of which perhaps four mean anything, so the other four are drawn as whatever falls out of the conditionals, usually nothing. A null arrives from somewhere and the component renders an empty box. A list is empty and the screen shows a blank where a sentence should be. None of these is a crash, which is why they survive review, and each is a state the screen decided not to draw and did not tell anybody.

The studio's systems are written in TypeScript with Effect, and Effect ships the three small types this study is about. Option is a value that may be absent and says so in its type. ReadonlyArray is a list that cannot be changed by the hand holding it. Match is a way to take a value from a closed set and say what happens for each member, and refuse to compile until every member has been said. Between them they run through most of the code, the exhaustive match in twenty-six files, Option in forty-two, the read-only list in a hundred and forty-six, and this study is about what that buys a screen.

Every state renders something.

Decision

The shapes are cut in one place and the screens model nothing. A state is a literal in a closed set declared in the kernel, the package that holds the domain, and the set is closed on purpose. Where a system does not yet know, the set carries a member that says so, unknown beside light, even and heavy, rather than a null standing in for the answer. That is the first move and everything else depends on it. A null is a state nobody wrote down, and a screen cannot be held to drawing a state nobody wrote down.

A leaf per state, and a wrapper that is nothing else. For every member of a union there is one component, named for the state and taking the fields that state needs rather than the object it came from, and above the leaves stands a wrapper whose whole body is a match over the union with exhaustiveness switched on. The letter row is three leaves, asking, kept and failed, and the wrapper picks one. When the domain gains a state, the union gains a member, and every wrapper that matches on that union stops compiling until somebody has drawn the new state. That is not a lint warning. It is the build refusing to produce a screen that would go blank.

Absence is matched, not tested. A search for an address in the store answers with an Option, and the caller matches on it with two arms, one for none and one for some, and both arms draw something. The rule the studio holds itself to is that the none arm never renders nothing, because an arm that returns null is a condition the screen decided to keep quiet about, and the whole point of the type was to make the condition loud. The same holds for a list. A read-only list is matched on empty and non-empty, and the empty case is a row of its own, a dashed outline that says nothing is here yet, rather than a gap where rows would have been.

The page says which state it is in. Every leaf carries two attributes, one naming what the thing is and one naming its standing, and the stylesheet keys off the standing rather than off a class the script toggles. A test selects by the same attribute. So the three places that could disagree about the row's state, the code that chose it, the sheet that paints it and the test that checks it, read one word off one element.

The studio's own site has no framework and keeps the shape anyway. The row is three templates, one per state, and a script that clones the one for the state that came back and swaps it in for the row standing. The standing is a union in the script's own type, and the swap takes the union minus the one state the page is born in. The form moves into the failed row because that row is still asking, and is gone with the kept one because a row that kept its form after taking the address would be offering to take it again.

Consequences

Adding a state is a tour of compile errors, and the tour is the feature. A screen that has not drawn a state does not ship, and a state that has been drawn has words, a mark and a line rather than a console message. Failure states get copy written for them because the build demands a leaf, and copy written for a failure is what turns an error into a sentence a reader can act on.

The screens stay thin. Because the union and the predicates live in the kernel and arrive as an import, a screen never derives a state of its own, and the same word that a route answers with is the word a row is painted by. When the arithmetic behind a state changes, no screen changes with it, since the shape was fixed before the code and the screens only ever matched on the shape. And because the events that produce a state are a record of what happened, a state on a screen can always be traced back to the rows it was folded from.

What we'd do differently

Exhaustiveness holds only where the set is closed. A string that arrives over the network is not a union until a schema has decoded it into one, and a screen that matches on the raw string is matching on nothing. The studio decodes at the edge and matches inside, and the discipline is only as good as the edge. Two files in the code still carry an arm that renders nothing for the none case, found by searching for the exact text, and they are a debt this study is a reminder of.

The row itself shows the older habit at its door. It is handed three separate facts, whether it is sending, whether it sent, and an error or null, and derives its union from them on the way in. That is the eight-combinations problem in miniature, solved one step too late, and the honest version hands the row the standing itself as one value with three members. It works because the derivation is a pure function with the union as its return type, so the compiler still holds it, but the shape would be truer with the union passed in. The cost of all of this is files. A leaf per state is more files than a component with a conditional in it, and a match over a wide union is long. The studio pays that, because the alternative is a state nobody drew, discovered by a reader.