Here is a task you have written twenty versions of: a product list screen. Load from the server, handle the error, render the list.

We handed it to an agent — Claude Opus 5 — and this came back.

class ProductsState {
  final List<Product>? items;
  final bool isLoading;
  final Exception? error;
}

That looks like something you would write yourself. Which is exactly the point: these models were trained on what all of us have been writing by hand for years.

Now count. Three fields: a bool, a nullable list, a nullable error. Each has two states you would care about. Two times two times two — eight combinations. How many of them mean anything?

isLoadingitemserrorWhat it means
truenullnullLoading
falsenullsetFailed
falsesetnullLoaded
truesetnull?
truenullset?
truesetset?
falsesetset?
falsenullnull?

Three. The other five are not compile errors. They are valid code. It builds, it passes review, it ships, and it sits there. isLoading is true and there is an error next to it — what do you render? Or everything is empty: not loading, no list, no error. Have we not started yet, or did we go and come back with nothing? The code does not know. It guesses, or the next person to open the file does.

Key takeaways

  • An impossible state is one your type permits and your domain forbids. It is the same thing as a broken invariant, seen from the other side.
  • The cost is not runtime bugs. It is a convention nobody wrote down, branches nobody ever considered, and a tax you pay at every read rather than once per incident.
  • The invariant always exists. The only question is whether a person is holding it — a comment, a wiki page, an assert that does not run in release — or whether breaking it does not compile.
  • enum Status plus nullable fields makes it worse, not better: twelve combinations instead of eight, and still three that mean anything.
  • A sealed class with non-nullable fields leaves exactly the meaningful states and makes the rest unbuildable. No booleans, no nullables, no !.
  • Exhaustiveness is the payoff. Add a fourth state and the compiler lists every place that does not handle it — which is feedback an agent can act on, not a wall it hits.
  • One _ => branch switches all of that off, silently. That is the single review rule worth taking from this article.
  • Algebraic data types do not fix everything. They eliminate impossible combinations of states, not impossible values, and they do nothing at a system boundary.

Impossible states, and the older name for them

Vocabulary first, because two words get used for one thing.

An impossible state is one your type permits and your domain forbids. A screen cannot be loading and showing an error at the same time. The type allows it. That is a hole.

The older and more precise name for it is invariant — a rule that has to hold at all times. If we are loading, there is no data yet. If there is an error, it has a message. An impossible state is just a broken invariant. Same fact from two sides: the invariant is how it is supposed to be, the impossible state is what you got when it was not.

Hold on to that word. It is where this turns.

Why this is a problem, and why the usual explanation is wrong

The usual line is that impossible states are bugs that do not crash — they quietly render the wrong screen. That sounds convincing, and if it were true the answer would be cheap: tests, QA, an alert on the empty screen. Catch it, fix it, move on.

That is not the problem. It is not about runtime at all. There are three costs, and all three are in your code right now, even while the app behaves perfectly.

One: the convention nobody wrote down. Look at the agent's class again. Where does it say that when isLoading is true you should not read items? Nowhere. Where does it say error and items are never both populated? Nowhere. Those rules exist and people rely on them, but they are not in the type and not in a comment. They live in the head of whoever wrote the class, and everyone after that reconstructs them from the code, reading the ifs and inferring intent. Five minutes a file, for every person who opens it.

Two: the number of branches. Eight combinations means eight cases, each of which somebody has to either handle or deliberately rule out. Three get handled. The other five are neither handled nor ruled out — they were never considered. That distinction matters: a ruled-out case is a decision, an unconsidered one is a hole.

And the count grows by multiplication.

Fields that travel togetherCombinationsMeaningful
383
4163–4
5323–4
6643–4

Three: you pay at every read, not once per bug. This is the important one. A bug gets fixed once. A convention that is not in the type gets re-derived by everyone who touches that screen — at review, when adding a feature, while digging through an incident. That is not a one-time cost, it is a tax.

Which is also where the usual patch comes from. People plug these holes with conditions: check we are not loading here, check the error is not empty there. Every one of those ifs is a patch over a hole you cut yourself when you declared three independent fields. Five holes, usually two patches — the ones that already fired.

And note, in passing: none of this crashes. No crash report, no stack trace, nothing in your error tracker. So "monitoring will catch it" does not apply. But that is a side effect, not the point.

What changed over the last couple of years is volume. This class used to appear once a week, written by a human who spent at least thirty seconds thinking about the fields. Now it appears in twelve seconds and nobody has read it — not the agent, not you. A reviewer is a person looking at three fields, not somebody holding eight combinations in their head.

Who is holding the invariant?

So: the question is never whether you have an invariant. You always do. The question is who is responsible for it.

Usually a person. A comment above the class. A wiki page nobody has opened since the year before last. A code review convention that two people out of five remember. At best an assert in the constructor.

An assert is not an invariant in Dart

Dart assert statements are stripped in release builds. Not "skipped" — the condition is never evaluated, and neither are its arguments, which is why an expensive check inside an assert costs nothing in production. The practical consequence is the part people forget: an invariant you defended with an assert does not exist where your users are. It held during development, where you were already watching, and it is absent in the one place you are not. Asserts are a debugging aid. They are not a constraint.

All of that is an invariant somebody is holding — by hand, by attention, by memory. The alternative is to arrange things so there is nothing to hold, because breaking it is not expressible in the type. Not "we check that this cannot happen" but "this does not compile."

And here is why that stopped being an academic distinction. An invariant held by a human works exactly as long as code is written at human speed. An agent has not read your comment, has not opened your wiki, was not in your code review. Conventions do not scale to generation speed. Compilers do.

Algebraic data types, in one pass

One disclaimer before the theory, to kill half the objections. The example here is deliberately the most obvious one possible. Loading, error, success is the single most over-used illustration of algebraic data types on the internet — it is in every tutorial. The idea has a famous slogan, make illegal states unrepresentable, usually credited to Yaron Minsky's Effective ML talks, and it has been around for well over a decade. Nothing here is a discovery. The interesting part is where that class at the top of this article came from, and what you do when code like it arrives in batches.

Now the definition. No category theory.

A product is "and". An ordinary class with fields: a string and a number and a bool. The number of values it can hold is the product of the counts. That is where the eight came from — it is not rhetoric, it is arithmetic. Every field you add multiplies.

A sum is "or". The value is either this or that, and there is no third option. The simplest sum everybody already knows is an enum. A sealed class is the same sum, except each case can carry its own data. Here the counts add: loading is one value, failed is however many errors exist, loaded is however many lists exist. Nothing gets multiplied for free.

The whole idea in one sentence: you cannot construct the type in a way that represents a state that was never valid.

"Why not just an enum?"

The obvious objection is that you do not need sealed classes for this — add an enum Status with three values and stop overthinking it. It is a reasonable instinct, and it does not work.

enum Status { loading, error, success }

class ProductsState {
  final Status status;
  final List<Product>? items;
  final Exception? error;
}

Count again: three status values, times two list states, times two error states. Twelve combinations. It was eight. Still three that mean anything. Arithmetically it got worse — nine pieces of garbage instead of five.

And the original problem is untouched. status is success and items is null: compiles fine. In the UI you are still writing items!, swearing to the compiler that the data is definitely there. A promise is not a guarantee. It is a disabled check with pleasant syntax.

The difference is what the sum carries. An enum is a sum without data — it tells you which state you are in, but the data sits alongside it, tied to the state only by your discipline. A sealed class is a sum with data: the list exists exactly where it means something, and nowhere else.

So the rule is: if your states carry no data, use an enum, it is shorter. The moment even one state has a payload, enum stops helping, and every ! in your UI is the receipt.

This is an old idea. ML, where it comes from, is fifty years old; Haskell, OCaml and F# have had it for decades. In the mainstream it is now essentially everywhere — Dart got it in version 3, in 2023. What changed is not the idea but the argument for it. ADTs used to be sold on elegance, and aesthetic arguments lose to sprints. The argument now is that code is written faster than it is read, the only reviewer keeping up with generation is the compiler, and anything you did not express in the type you expressed as a hope.

The rewrite

Three steps, about two minutes of work.

Step one: write the states down in words, before any code. The screen is loading. The screen failed. The screen has a list. That is it — three. If you end up with seven at this step, you are almost certainly writing down flag combinations rather than states. A state is something you can name in one word and point at on a mockup.

Step two: each state becomes its own class.

sealed class ProductsState {
  const ProductsState();
}

final class Loading extends ProductsState {
  const Loading();
}

final class Failed extends ProductsState {
  const Failed(this.error);
  final Exception error;
}

final class Loaded extends ProductsState {
  const Loaded(this.items);
  final List<Product> items;
}

Look at what happened. Failed holds an error, and it is not nullable — the state "we failed but there is no error" no longer exists, and you physically cannot construct it. Loaded holds a list, also non-nullable. Loading holds nothing, because during loading there is no data, and keeping a field for it is precisely an invitation to an impossible state.

Notice what is not there. Not one boolean. Not one nullable field. That is the entire refactor; the rest is syntax.

One Dart-specific detail: sealed restricts subtyping to the same library, which in practice means the same file unless you are deliberately splitting it with part. That is not pedantry, it is the mechanism. To check exhaustiveness the compiler has to know the complete list of cases, and it can only guarantee that within the library. Hence the convention — however many cases you have, they live together in one file. It looks odd the first time and then turns out to be convenient: the screen's whole state space is visible at a glance.

Step three: the UI.

Widget build(BuildContext context) {
  return switch (state) {
    Loading() => const AppSpinner(),
    Failed(:final error) => ErrorView(error),
    Loaded(:final items) => ItemList(items),
  };
}

Not one if. Not one null check. The data is destructured in the pattern — :final items — and arrives already typed. Inside the Loaded branch the list exists, rather than "might exist." Compare that to what you would have on flags: a chain of checks whose order matters and whose reason nobody remembers.

Doing this on a project with forty screens

Do not rewrite everything. The rule: new screen, sealed from the start; old screen, when you are already in there fixing something. A screen nobody has touched in two years, leave alone. It may well have impossible states, but they have either already fired or never will.

For the ones you do touch, priority comes down to one thing: how many places read that state. One switch in one widget — rewriting barely pays, since you can see everything anyway. A state read from six places, across the UI, analytics, logging and a push handler, is where exhaustiveness earns its keep, because those are exactly the five out of six that get forgotten. If a screen already funnels its state through a single stream rather than a pyramid of flags, that is the cheapest place to start.

The same type in four other languages

Not because you need a second language, but so it is clear this is not a Dart peculiarity or a Flutter-community fashion. Same type, three states, data attached to the state.

Kotlin.

sealed interface ProductsState

data object Loading : ProductsState

data class Failed(
    val error: AppError,
) : ProductsState

data class Loaded(
    val items: List<Product>,
) : ProductsState

when used as an expression has to cover every branch — exactly our switch.

Rust. More interesting, because in Rust this is the built-in enum:

enum ProductsState {
    Loading,
    Failed(AppError),
    Loaded(Vec<Product>),
}

In Rust an enum is a sum with data from the start, with no extra ceremony. Miss a case in a match and it does not compile.

Swift, the same thing with associated values:

enum ProductsState {
    case loading
    case failed(AppError)
    case loaded([Product])
}

And the exotic one, Idris — the ML syntax all of the above descend from:

data ProductsState
  = Loading
  | Failed AppError
  | Loaded (List Product)

Look at the vertical bar. It reads as "or". The sum type is literally written with an or-symbol — this is a sum long before anybody called it a sealed class.

Idris also does something none of the other four can:

import Data.Vect

data ProductsState : Type where
  Loading : ProductsState
  Failed  : AppError -> ProductsState
  Loaded  : Vect (S n) Product -> ProductsState

Vect (S n) in the type means a list guaranteed to have at least one element. Not "we checked", not "we agreed" — you simply cannot construct Loaded with an empty list. Remember this one; it comes back at the end.

One idea, five languages, exhaustiveness in all of them. If you are not on Dart, everything below works the same for you.

Exhaustiveness, which is what all of this was for

A month later a requirement arrives: when the list comes back empty, show a placeholder with a button. That is not an error and it is not success-with-data. It is a fourth state.

final class Empty extends ProductsState {
  const Empty();
}

One line. Nothing else changed. And the project does not build:

The type 'ProductsState' is not exhaustively matched by the switch
cases since it doesn't match 'Empty()'.

The compiler lists every place the new state is not handled. Not one of them — all of them.

Now, why this belongs in an article about AI-written code. "Add an empty state to the list screen" is exactly the size of task people hand to an agent without looking. The agent will add the class, update the switch it had in context, and not update the other two — the one in analytics and the one behind the refresh button. They were not in the prompt, so as far as it is concerned they do not exist.

On flags, that ships. There is no diff to catch it, because the diff only contains what the agent changed. Tests are green, because a state that did not exist a month ago has no tests by definition. You find out from a user six weeks later, in the form of "sometimes I just get a blank screen."

On sealed classes it ships nowhere. It does not build.

And here is the part worth taking away. Exhaustiveness is not protection from AI. It is feedback for it. What makes an agent bad at this is that it has no access to the list in your head of "and also do not forget this bit over here." The compiler emits that list in machine-readable form, with files and line numbers. The agent hits red and fixes all three, which it is genuinely good at, because it was told exactly where. You are not stopping it from working — you are giving it the thing it was missing. The same mechanism helps a human once a week and an agent every twenty minutes.

The one line that switches it all off

return switch (state) {
  Loading() => const AppSpinner(),
  _ => ItemList(state.items),
};

One underscore and exhaustiveness is dead. Quietly, with no warning. A catch-all handles everything by definition, so there is nothing left for the compiler to check. Add a fifth state, a sixth, a tenth — it builds, and silently falls through to here.

If you take exactly one review rule out of this article, take this one:

A switch over a sealed type should not have a default branch.

It is the one line here you actually have to check by eye — and a lint rule catches it, so you do not even have to.

Where algebraic data types do not help

Four places where they do not work, or work against you. Without this section the rest is a sermon.

A sum does not cure a product. You split the screen into three states, which is good. But Loaded is still an ordinary class with fields, and if there are twelve of them and half are nullable, you have moved the swamp down one floor. ADTs are about which states exist, not about what is inside a state.

States that overlap — the most common mistake people make when they migrate. A pull-to-refresh requirement lands: the list is already on screen and you are fetching a fresh one. Is that Loading or Loaded? The naive answer is a Refreshing case with items in it. A week later you have Refreshing, RefreshingAfterError and FailedButHasCache — the same combinatorial explosion, now in classes, which is worse than flags because it is more verbose.

The right answer upsets people:

final class Loaded extends ProductsState {
  const Loaded(this.items, {this.isRefreshing = false});
  final List<Product> items;
  final bool isRefreshing;
}

Yes, the boolean is back, and that is fine. It now lives inside a state where it means something: "we have a list and we are refreshing" is a real situation you need to express. And "refreshing with no list" no longer exists, because there is nowhere to put it.

Sums for what is mutually exclusive, products for what co-occurs

This is the rule that decides whether the refactor helps or hurts. Two things that can never be true at once want a sum — separate cases of a sealed class. Two things that are routinely true at the same time want a product — fields sitting side by side inside one case. Getting those backwards is the main way to end up worse off than you started: mutually exclusive states modelled as parallel booleans give you the eight-combination problem, and co-occurring facts modelled as separate cases give you a class per combination.

Types do not enforce every kind of invariant, and this matters more than the rest. A sealed class expresses a structural invariant beautifully — which states exist, and which data travels with which state. It expresses nothing about invariants on values: this list is sorted, the start date precedes the end date, the line items sum to the total, this string is a valid email. All of those are still held by a person, and no amount of sealed helps.

That Idris snippet is exactly the counter-example. Vect (S n) — non-empty, in the type — is a value invariant pushed into the type system. The technique is called dependent types, and you could express "sorted" and "start before end" the same way. The price is a language you do not write in and, realistically, will not. In practice you reach for a different technique instead: a private constructor with validation, so a value of it cannot be created in an invalid shape.

The same bucket holds system boundaries. JSON off the wire is not typed. ADTs start after parsing, and a sealed class will not save you when the backend sends "succes" with one "s" — explicit deserialization with hand-written keys will. The sentence worth keeping: ADTs eliminate impossible combinations of states, not impossible values.

And over-engineering. A sealed class with two cases and no data is an enum, and the enum reads faster. A sealed class with one case is a class. An honest boolean stays an honest boolean — isSelected on a checkbox has exactly two states, both valid, and an ADT improves nothing there. The test is simple: count the combinations and cross out the meaningless ones. Nothing to cross out, leave it alone.

Finally, the honest cost: it is verbose. Four states in Dart is about thirty lines, against four fields in the original. The "slower to write" half of that argument does not survive 2026 — that is precisely the work an agent does in seconds without mistakes. The "slower to read" half survives, and that is the price you pay.

Find yours in thirty minutes

Reviewing a state class

Ten checks in three groups. Anything unchecked is a decision somebody has not made yet.

The type itself

  • Mutually exclusive situations are separate cases of a sealed type, not parallel booleans
  • Every field inside a case is non-nullable, or the null means something specific and documented
  • Data lives on the state that owns it, not alongside a status enum
  • Facts that co-occur are fields inside one case, not a case per combination

Every switch over it

  • No default branch and no underscore pattern over a sealed type
  • No null-assertion operator on a field the state is supposed to guarantee
  • Every reader of the state is covered, including analytics, logging and background handlers

What types cannot do for you

  • Value invariants — sorted, non-empty, in range, well-formed — are enforced in a constructor, not assumed
  • Parsing at the system boundary validates explicitly rather than trusting the shape
  • Asserts are treated as a debugging aid, since they do not run in release builds

The short version

An impossible state is one your type permits and your domain forbids, which is the same thing as a broken invariant. It is expensive not because it breaks something at runtime, but because every convention that is not in the type gets re-derived by somebody at every single read.

The invariant always exists. The only question is whether a person is holding it — with a comment, a wiki page and an assert that does not run in release — or whether there is nothing to hold, because breaking it does not compile.

Three fields that travel together, eight combinations, three of them meaningful. A sealed class with non-nullable fields leaves exactly those three and makes the other five unbuildable. Exhaustiveness is the payoff: it turns "somebody forgot to update the handling" into "the project does not build", and it works the same regardless of who forgot. A default branch switches all of it off, quietly.

Here is the larger shift. Types used to be how you explained your thinking to a colleague opening the file six months later. Now they are the only way to explain it to something writing code faster than you can read it. The craft did not go anywhere — it moved up a level. You used to write the implementation. Now you write the boundaries the implementation has to stay inside, and anyone can fill them in, including a machine.

Frequently asked questions

A state your type permits and your domain forbids. A screen state class with a loading boolean, a nullable list and a nullable error has eight combinations, but only three of them describe a situation the product can actually be in. The remaining five compile, pass review and ship. The same idea is more precisely called a broken invariant: the invariant is the rule that has to hold at all times, and the impossible state is what you get when the type does not enforce it.
A way of describing how many values a type can hold. A product type is and — an ordinary class with fields, whose value count is the product of its field counts, so every field you add multiplies. A sum type is or — the value is one case or another and there is no third option, so the counts add instead of multiplying. An enum is a sum without data; a sealed class is a sum where each case carries its own data. The point of using sums is that you cannot construct a value representing a state that was never valid.
Because it makes the arithmetic worse. An enum with three values next to a nullable list and a nullable error gives twelve combinations rather than eight, and still only three of them mean anything. The original problem is untouched: status can be success while the list is null, that compiles, and the UI ends up asserting that the field is non-null. An enum tells you which state you are in but leaves the data alongside it, tied to the state only by convention. A sealed class attaches the data to the state, so the list exists exactly where it means something.
In the same library, which in practice means the same file unless you deliberately split it with part. That is the mechanism rather than a style rule: to check exhaustiveness the compiler needs the complete list of subtypes, and it can only guarantee completeness within the library. The convention that follows is to keep every case of a state together in one file, which turns out to be convenient — the whole state space of a screen is visible at a glance.
Because a catch-all handles every case by definition, so there is nothing left for the compiler to verify. Once a switch over a sealed type has a default or underscore branch, adding a fifth or a tenth state compiles cleanly and silently falls through to that branch instead of failing the build. This is the single highest-value review rule on the subject: a switch over a sealed type should not have a default branch, and a lint rule can enforce it.
Yes, but not as a barrier. Exhaustiveness is feedback rather than protection. An agent asked to add a new state will update the switch it had in context and miss the ones it did not, because they were not in the prompt. With flags that ships silently, since the diff only shows what changed and a brand-new state has no tests. With a sealed type the build fails and the compiler names every unhandled location with a file and a line number, which is exactly the input an agent is good at acting on.
Four places. They do not fix a case that is itself a bloated product type with twelve nullable fields. They hurt when used for states that genuinely overlap, such as refreshing while data is already on screen, which wants a boolean inside the loaded case rather than a new case. They express nothing about value invariants such as sorted, non-empty or a valid email, which still need a validating constructor, and they start only after parsing, so they do nothing at a system boundary either, where the backend sends a misspelled status string. And they turn into over-engineering wherever an enum or an honest boolean would have done.

If you want the wider list of what unsupervised agents do to Flutter code, this is number three on our seven-item breakdown, and the process we use to keep it from happening is written up in how we build with AI. This article is the companion to our video episode on the same subject; the previous one was your SSL pinning probably is not working.

Run the exercise and tell us how many rows you crossed out, and on which screen. Our bet is that most people land on the same three states we did.


Ilya Nixan is Founder & Lead Developer at Nerdy Production, a Flutter-first agency that builds and maintains apps across fintech, healthcare, and retail. We also run an AI code audit for teams whose codebase grew faster than anyone could review it.