DEV Community

Cover image for Make illegal states unrepresentable, until you have to explain them
Tom Horvat
Tom Horvat

Posted on Originally published at underdroid.hashnode.dev

Make illegal states unrepresentable, until you have to explain them

How to decide which constraints belong in your types and which belong in your validator.


A config file with eight things wrong with it

A config file lands with eight things wrong with it. Your loader is built by the
book, every constraint in the type system, so it stops on the first one and
throws. The person who wrote the config fixes that, re-uploads, and meets the
second. Six more after that.

Fail fast, fail loud. Your loader did both, eight times, one at a time.

"Make illegal states unrepresentable" optimizes for preventing bad states,
but some systems have to explain them, and those two goals pull your type
design in opposite directions.

Every principle has a range

Parse, don't validate.
Make illegal states unrepresentable. Encode your invariants in the type system,
and once a value exists it is valid by construction. Push the mess to the
boundary and let the core trust what it is handed. A NonEmptyList can't be
empty. A sealed Payment can't be Cash and
Card at once. Whole categories of defensive checks disappear, and the bugs that
hid in them go too.

Fail fast, fail loud has its own time and place. Early on, when the shape of the
thing is still moving and you are the only person who will ever read the error,
stopping at the first problem is the cheapest feedback there is. You read the
trace, you fix it, you move on.

It stops fitting the moment the values are entered by someone else and have to
agree with each other. From their side, submitting is one step. Fail fast answers
it eight separate times, and never once says how many are left.

The requirement that breaks it

Take for example the loader I've been building in Kotlin. It reads campaign
configs: a date window, a target segment, a discount, localized copy per market.
It has one hard rule: when a config is invalid, it must report every
violation in a single pass, each as a stable (code, path) pair, rather than
failing on the first one it finds. The result type is literally shaped for it:

data class ValidationError(
    val code: ValidationCode,
    val path: String,
    val message: String? = null,
)

sealed interface ValidationResult {
    data object Valid : ValidationResult
    data class Invalid(val errors: List<ValidationError>) : ValidationResult
}
Enter fullscreen mode Exit fullscreen mode

That requirement hits a wall:

Unrepresentable means fail-fast, by construction.

If you cannot construct the invalid value, you cannot hold it, and if you
can't hold it you can't inspect it for all eight of its faults. A type that
refuses the first mistake has, in the same motion, made the other seven
unreportable in the same pass. The very property that makes "unrepresentable"
safe, that the value never comes into being, is the property that makes
accumulating errors impossible.

So an error-accumulating validator is not a failure to apply the principle. It's
a requirement the principle cannot express.

The split

The resolution is to stop treating "type-level" versus "runtime check" as a
purity contest and treat it as a routing decision. Every constraint goes to one
of two places, by a rule you can state in one line:

Forbid by construction what is total and local. Forbid by validator what is
contextual or user-facing.

Routing this way also gives each layer a job it can actually do. The input form
stops you typing letters into a number field. The type system stops you writing
down a shape that has no meaning. The validator answers the only question left,
which is whether a well-formed config makes sense as a whole. That last one is
the only guarantee of the three: the form is one input path among several, and the
validator has to hold whether the config arrived through your UI, a file, or
somebody's script.

The two halves of the rule overlap, and the overlap is deliberate. A bounds check
on a single field is total and local, so the first half claims it. If that bound
is something a person has to be told about, the second half takes it anyway. When
both apply, reporting wins.

Forbid by construction: total and local

When a constraint is about a single field or a closed set of shapes, the type
system is exactly right, and you should use it. Case in point, the discount is a
sealed hierarchy:

@Serializable
@JsonClassDiscriminator("type")
sealed interface Discount {
    @Serializable
    @SerialName("PERCENTAGE")
    data class Percentage(val percent: UInt, /* ... */) : Discount

    @Serializable
    @SerialName("FIXED")
    data class Fixed(val amountMinor: Long, val currency: String, /* ... */) : Discount
}
Enter fullscreen mode Exit fullscreen mode

A currency (a FIXED concept) on a PERCENTAGE discount is not something the
validator rejects. It is something you cannot write down. There is no
validation rule for it, because there is no invalid state to catch. This is the
principle working perfectly, and I lean on it hard: whenever a shape has disjoint
fields keyed by a discriminator, it becomes a sealed hierarchy, precisely so that
mismatched combinations never exist.

Forbid by validator: contextual or user-facing

The other place is for constraints that no local type can hold, or that must be
reported rather than prevented. Two flavors:

Contextual (cross-entity) constraints. A localized copy entry names a market,
but it's only valid if that market is actually covered by the audience segment
this campaign targets
. That's a fact about three entities at once (the copy
entry, the campaign that holds it, and the segment it targets). No field type on
the copy entry can encode "a market that exists somewhere else in the graph." So
it's a validator rule, running inside the accumulator where add appends to the
error list and index is a prebuilt lookup:

val markets = index.marketIdsBySegment[campaign.segmentId]
if (markets != null) {
    campaign.copy.forEach {
        if (it.marketId !in markets)
            add(
                ValidationError(
                    ValidationCode.UNTARGETED_COPY_MARKET,
                    "campaign[${campaign.id}].copy[${it.marketId}]"
                )
            )
    }
}
Enter fullscreen mode Exit fullscreen mode

Deliberately permissive fields. Some fields are left looser than the domain
allows
, on purpose, so the violation has a name. Percentage.percent is a
UInt, and the type happily permits 120u. The domain caps it at 100, but
instead of reaching for a
Percent value class, I let
the value exist and let a validator rule name it:

if (discount is Discount.Percentage && discount.percent > 100u)
    add(
        ValidationError(
            ValidationCode.DISCOUNT_OUT_OF_RANGE,
            "campaign[${campaign.id}].discount.percent"
        )
    )
Enter fullscreen mode Exit fullscreen mode

The permissiveness is the point. A value that can exist can be pointed at, and
a value that can be pointed at can produce DISCOUNT_OUT_OF_RANGE at path
campaign[...].discount.percent, which the form can render next to the offending
field. An unconstructable value can't be pointed at; it can only crash the
decoder.

What the split buys you

This produces a clean two-phase ingestion:

  1. Decode: structural only. kotlinx.serialization against a strict codec: unknown keys rejected, no lenient coercion, no special float values. This phase answers "is this the right shape?"
  2. Validate: semantic, accumulating. The validation rules run over the decoded graph and collect every (code, path). This phase answers "does this shape make sense?"

Each phase has exactly one job, and the payoffs compound:

  • The types stay simple. Flat data classes, no wrapper types, no hand-written invariants buried in init {} blocks. That matters extra here because these same types are the wire format. Every constructor you'd add for safety is a serialization edge case you'd owe forever.
  • Errors become a product surface. Each ValidationCode plus its path is enough to drive field-level highlighting in the form. The validator isn't a gate that says "no"; it's a function that says "here are all eight things to fix, and where each one is."

That last part is not a figure of speech. Here is that eight-mistake config,
uploaded on 14 April against a segment covering de-DE, en-GB, en-US and es-ES:

{
  "id": "promo-spring",
  "window": { "start": "2026-04-01", "end": "2026-03-25" },
  "segmentId": "seg-eu-shoppers",
  "discount": { "type": "PERCENTAGE", "percent": 120 },
  "copy": [
    { "marketId": "fr-FR", "headline": "Offre de printemps" },
    { "marketId": "en-GB", "headline": "Spring offer" },
    { "marketId": "en-GB", "headline": "Spring sale" },
    { "marketId": "en-US", "headline": "" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

And here is what comes back:

WINDOW_ENDS_BEFORE_START   campaign[promo-spring].window
WINDOW_ALREADY_ELAPSED     campaign[promo-spring].window
DISCOUNT_OUT_OF_RANGE      campaign[promo-spring].discount.percent
UNTARGETED_COPY_MARKET     campaign[promo-spring].copy[fr-FR]
MISSING_COPY_FOR_MARKET    campaign[promo-spring].copy[de-DE]
MISSING_COPY_FOR_MARKET    campaign[promo-spring].copy[es-ES]
DUPLICATE_COPY_MARKET      campaign[promo-spring].copy[en-GB]
EMPTY_COPY_HEADLINE        campaign[promo-spring].copy[en-US].headline
Enter fullscreen mode Exit fullscreen mode

One upload, one pass, eight rows. Note that the code alone isn't the identity:
MISSING_COPY_FOR_MARKET shows up twice, and two different rules land on
.window. The pair is what points at a field, which is why the pair is what the
validator returns.

The cost, honestly

This isn't free, and pretending otherwise would undercut the point.

The core now trusts a type that can hold a semantically-invalid value in the
window between decode and validate. percent really can be 120u in a decoded
Campaign object. So you need one piece of discipline: nothing downstream of
validation runs on an Invalid result.
The validate() call is the gate; past
it, the graph is trusted (and the config is frozen once the campaign goes live,
so it can't drift back into invalidity).

The other cost is judgment: the rule doesn't apply itself. Forbid by validator
only when the constraint is contextual or must be reported. If a constraint is
total, local, and you never need to accumulate, if one violation is genuinely
catastrophic to even hold in memory, then reach for the sealed type or the value
class. Don't make something permissive just because you can. Make it permissive
when the error needs a name.

So where's the line?

"Make illegal states unrepresentable" is not wrong. It's a tool with a blast
radius. Applied to a total, local constraint, it deletes bugs for free. Applied to
a constraint your users need explained, it turns an eight-item fix-it list into
eight round-trips.

Both ideas are correct at once: some states should be unrepresentable, and some
error sets must be reported whole. The skill is not picking a side. It's knowing which
constraint belongs on which side of the decode/validate line.

Top comments (0)