openlang

Opaque types carry more of the model than Result does

Everyone praises Gleam for errors as values, and fair enough, Result is lovely. But for data modeling the feature I lean on harder is opaque.

pub opaque type Email { Email(String) } means other modules can see the type and can't call the constructor. The only way to get an Email is through whatever the module exports, say fn parse(raw: String) -> Result(Email, EmailError). So the Result shows up once, at the edge, and everything downstream takes an Email and never checks again.

Without opaque you still get the Result at the edge and no guarantee afterward, because anybody can write Email("lol") and skip the check. The error value tells you a check failed. The opaque type tells you a check happened.

Elm gets there by not exposing the constructor from the module, same idea with less ceremony. I like that Gleam spells it on the type itself, right where you read the definition.

The conversation continues 4

Follow the reasoning
  1. Permalink

    The guarantee is narrower than it looks. Haskell gets the same effect by leaving the constructor out of the export list, module Email (Email, parse) where, and has the same limit. The invariant holds outside the module provided every function inside it respects it. If someone later adds a helper in that module that builds an Email straight from raw text, the type still says Email and the check never happened. What the hidden constructor buys is a bound on how much code you have to read, the module rather than the whole program. That is still a considerable reduction.

  2. Permalink

    One type, two meanings, picked by which getter you call. That's a JavaScript Date, and it has been for decades. It holds one instant, a count of milliseconds since the epoch, and then half its methods pretend it's a wall-clock time in whatever zone the machine happens to be in.

    Temporal splits them. Temporal.Instant for a point in time, Temporal.PlainDate for a calendar date with no zone, Temporal.ZonedDateTime when you really need both. A birthday is a PlainDate. Storing it as a Date at local midnight is exactly how a birthday slides back a day when someone reads it from a zone further west.

    The type does the modeling. Same lesson as liesl's Email, with more ways to get it wrong.

  3. Permalink

    A private constructor plus a validating named one is honestly the cheapest typing win I know for template-heavy code. In PHP you make the constructor private, add public static function fromString(string $raw): self that validates, and mark the property readonly (that part needs PHP 8.1). Now a parameter typed Email $to actually means something.

    The catch is that legacy templates pass arrays around everywhere, so the value object only helps from the point where you convert. Start at the edges where request data comes in and leave the templates alone. They'll get typed eventually or they won't.

    And yes, ReflectionClass::newInstanceWithoutConstructor() can skip all of it. Nobody does that by accident.

Other sides of the same question

  1. When an empty palette is already a decision

    An empty color list means two different things in a swatch editor, and Save should be able to tell them apart. Color validity…