Hier is een taak waarvan je twintig versies hebt geschreven: een scherm met een productlijst. Ophalen van de server, de fout afhandelen, de lijst tonen.

We gaven hem aan een agent — Claude Opus 5 — en dit kwam terug.

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

Dat ziet eruit als iets wat je zelf zou schrijven. Dat is precies het punt: deze modellen zijn getraind op wat wij allemaal al jaren met de hand schrijven.

Tel maar. Drie velden: een bool, een nullable lijst en een nullable fout. Per veld twee toestanden die ertoe doen. Twee keer twee keer twee — acht combinaties. Hoeveel daarvan betekenen iets?

isLoadingitemserrorWat het betekent
truenullnullLaden
falsenullgevuldMislukt
falsegevuldnullGeladen
truegevuldnull?
truenullgevuld?
truegevuldgevuld?
falsegevuldgevuld?
falsenullnull?

Drie. De andere vijf zijn geen compileerfouten. Het is geldige code. Het compileert, het komt door de review, het gaat live en het blijft daar liggen. isLoading staat op true en er ligt een fout naast — wat render je? Of alles is leeg: niet aan het laden, geen lijst, geen fout. Zijn we nog niet begonnen, of zijn we gegaan en met niets teruggekomen? De code weet het niet. Hij gokt, of de volgende die het bestand opent gokt.

Kernpunten

  • Een onmogelijke state is er een die je type toelaat en je domein verbiedt. Het is hetzelfde als een gebroken invariant, van de andere kant bekeken.
  • De kosten zijn geen runtimebugs. Het gaat om een afspraak die niemand heeft opgeschreven, om takken die niemand ooit heeft overwogen, en om een belasting die je bij elke leesbeurt betaalt in plaats van één keer per incident.
  • De invariant bestaat altijd. De enige vraag is of een mens hem vasthoudt — een commentaar, een wikipagina, een assert die in release niet draait — of dat er niets vast te houden valt omdat je hem niet kúnt breken: dat compileert niet.
  • enum Status plus nullable velden maakt het erger, niet beter: twaalf combinaties in plaats van acht, en nog steeds drie die iets betekenen.
  • Een sealed class met niet-nullable velden houdt precies de zinvolle states over en maakt de rest onbouwbaar. Geen booleans, geen nullables, geen !.
  • Volledigheidscontrole is de opbrengst. Voeg een vierde state toe en de compiler somt elke plek op die hem niet afhandelt — dat is feedback waar een agent iets mee kan, geen muur waar hij tegenaan loopt.
  • Eén _ =>-tak zet dat allemaal uit, stilzwijgend. Dat is de enige reviewregel die je uit dit artikel hoeft mee te nemen.
  • Algebraïsche datatypes repareren niet alles. Ze elimineren onmogelijke combinaties van states, niet onmogelijke waarden, en op een systeemgrens doen ze niets.

Onmogelijke states, en hun oudere naam

Eerst de terminologie, want er zijn twee woorden voor één ding.

Een onmogelijke state is er een die je type toelaat en je domein verbiedt. Een scherm kan niet tegelijk aan het laden zijn en een fout tonen. Het type staat het toe. Dat is een gat.

De oudere en preciezere naam is invariant — een regel die te allen tijde moet gelden. Als we laden, is er nog geen data. Als er een fout is, heeft die een bericht. Een onmogelijke state is simpelweg een gebroken invariant. Hetzelfde feit van twee kanten: de invariant is hoe het hoort te zijn, de onmogelijke state is wat je krijgt als het dat niet is.

Onthoud dat woord. Daar draait het om.

Waarom dit een probleem is, en waarom de gebruikelijke uitleg niet klopt

Het gebruikelijke verhaal luidt dat onmogelijke states bugs zijn die niet crashen: ze renderen stilzwijgend het verkeerde scherm. Dat klinkt overtuigend, en als dát het probleem was, zou het antwoord goedkoop zijn: tests, QA, een alert op het lege scherm. Vangen, fixen, door.

Dat is het probleem niet. Het gaat helemaal niet over runtime. Er zijn drie kostenposten, en alle drie zitten op dit moment in je code, ook als de app zich perfect gedraagt.

Eén: de afspraak die niemand opschreef. Kijk nog eens naar de klasse van de agent. Waar staat dat je items niet moet lezen als isLoading true is? Nergens. Waar staat dat error en items nooit allebei gevuld zijn? Nergens. Die regels bestaan en mensen vertrouwen erop, maar ze staan niet in het type en niet in een commentaar. Ze leven in het hoofd van wie de klasse schreef, en iedereen daarna reconstrueert ze uit de code, door de ifs te lezen en de bedoeling af te leiden. Vijf minuten per bestand, voor iedereen die het opent.

Twee: het aantal takken. Acht combinaties betekenen acht gevallen, en met elk daarvan moet iemand iets doen: afhandelen of bewust uitsluiten. Er worden er drie afgehandeld. De andere vijf zijn niet afgehandeld en niet uitgesloten — ze zijn nooit overwogen. Dat onderscheid telt: een uitgesloten geval is een besluit, een niet overwogen geval is een gat.

En dat aantal groeit niet met optellen maar met vermenigvuldigen.

Velden die altijd samen voorkomenCombinatiesZinvol
383
4163–4
5323–4
6643–4

Drie: je betaalt bij elke leesbeurt, niet één keer per bug. Dit is de belangrijkste. Een bug wordt één keer gefixt. Een afspraak die niet in het type staat, wordt opnieuw afgeleid door iedereen die dat scherm aanraakt — bij de review, bij het toevoegen van een feature, tijdens het uitzoeken van een incident. Dat zijn geen eenmalige kosten, dat is een belasting.

Daar komt ook de gebruikelijke pleister vandaan. Mensen dichten deze gaten met condities: hier controleren we dat we niet laden, daar dat de fout niet leeg is. Elk van die ifs is een pleister op een gat dat je zelf hebt geslagen toen je drie onafhankelijke velden declareerde. Vijf gaten, meestal twee pleisters — die op de gaten waar al iemand in is gevallen.

En let op, terzijde: niets hiervan crasht. Geen crashrapport, geen stacktrace, niets in je foutentracker. Dus het argument dat monitoring het wel opvangt, gaat niet op. Maar dat is een bijeffect, niet de kern.

Wat de afgelopen paar jaar veranderde, is het volume. Deze klasse verscheen vroeger één keer per week, geschreven door een mens die minstens dertig seconden over de velden nadacht. Nu verschijnt hij in twaalf seconden en heeft niemand hem gelezen — de agent niet, jij niet. En een reviewer is een mens die naar drie velden kijkt, niet iemand die acht combinaties in zijn hoofd houdt.

Wie de invariant vasthoudt

De vraag is dus nooit óf je een invariant hebt. Die heb je altijd. De vraag is wie ervoor verantwoordelijk is.

Meestal een mens. Een commentaar boven de klasse. Een wikipagina die al twee jaar niemand meer heeft geopend. Een reviewafspraak die twee van de vijf mensen zich herinneren. In het beste geval een assert in de constructor.

Een assert is in Dart geen invariant

Dart verwijdert assert-statements uit releasebuilds. Niet uitgeschakeld, maar verwijderd: de conditie wordt nooit geëvalueerd en de argumenten evenmin, en daarom kost een dure controle binnen een assert in productie niets. Het praktische gevolg is precies het deel dat mensen vergeten: een invariant die je met een assert verdedigde, bestaat niet voor je gebruikers. Hij gold tijdens de ontwikkeling, waar je toch al keek, en hij ontbreekt op de enige plek waar je niet kijkt. Een assert is een debughulpmiddel. Het is geen beperking.

Dat is allemaal een invariant die iemand vasthoudt — met de hand, met aandacht, uit het hoofd. Het alternatief is het zo inrichten dat er niets vast te houden valt, omdat je het breken ervan niet in het type kunt uitdrukken. Niet "wij controleren dat dit niet kan gebeuren", maar "dit compileert niet".

En daarom hield dat op een academisch onderscheid te zijn. Een invariant die een mens vasthoudt, werkt precies zolang code op menselijke snelheid wordt geschreven. Een agent heeft je commentaar niet gelezen, je wiki niet geopend en niet bij je codereview gezeten. Afspraken schalen niet mee met generatiesnelheid. Compilers wel.

Algebraïsche datatypes in één keer

Eén voorbehoud vóór de theorie, om de helft van de tegenwerpingen af te vangen. Het voorbeeld hier is bewust het meest voor de hand liggende dat er is. Laden, fout, succes is de meest afgekloven illustratie van algebraïsche datatypes op internet; hij staat in elke tutorial. Het idee heeft een beroemde leus, make illegal states unrepresentable, meestal toegeschreven aan de lezingen van Yaron Minsky over effectief ML, en die bestaat al ruim tien jaar. Hier wordt niets ontdekt. Het interessante is waar die klasse bovenaan dit artikel vandaan kwam, en wat je doet als zulke code bij vrachtladingen tegelijk binnenkomt.

Nu de definitie. Zonder categorietheorie.

Een product is "en". Een gewone klasse met velden: een string en een getal en een bool. Het aantal mogelijke waarden is het product van de aantallen. Daar kwamen die acht vandaan: dat is geen retoriek, dat is rekenen. Elk veld dat je toevoegt vermenigvuldigt.

Een som is "of". De waarde is dit of dat, en een derde optie bestaat niet. De eenvoudigste som die iedereen al kent, is een enum. Een sealed class is diezelfde som, alleen kan elk geval zijn eigen data dragen. Hier worden de aantallen opgeteld: laden is één waarde, mislukt is zoveel als er fouten zijn, geladen is zoveel als er lijsten zijn. Er wordt niets gratis vermenigvuldigd.

Het hele idee in één zin: je kunt het type niet zo construeren dat het een state voorstelt die nooit geldig was.

"Waarom niet gewoon een enum?"

De voor de hand liggende tegenwerping is dat je hier geen sealed classes voor nodig hebt: voeg een enum Status met drie waarden toe en doe niet zo moeilijk. Dat instinct is redelijk, en het werkt niet.

enum Status { loading, error, success }

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

Tel opnieuw: drie statuswaarden, maal twee lijsttoestanden, maal twee fouttoestanden. Twaalf combinaties. Het waren er acht. Nog steeds drie die iets betekenen. Rekenkundig is het dus slechter geworden: negen zinloze combinaties in plaats van vijf.

En het oorspronkelijke probleem staat er nog steeds. status is success en items is null: compileert prima. In de UI schrijf je nog steeds items!, waarmee je de compiler bezweert dat de data er echt is. Een belofte is geen garantie. Het is een uitgezette controle met prettige syntaxis.

Het verschil zit in wat de som draagt. Een enum is een som zonder data: hij vertelt je in welke state je zit, maar de data ligt ernaast, aan de state gekoppeld door alleen jouw discipline. Een sealed class is een som met data: de lijst bestaat precies daar waar hij iets betekent, en nergens anders.

De regel is dus: dragen je states geen data, gebruik dan een enum, die is korter. Zodra ook maar één state een lading heeft, houdt de enum op te helpen, en elke ! in je UI is de rekening.

Het is een oud idee. ML, waar het vandaan komt, is vijftig jaar oud; Haskell, OCaml en F# hebben het al decennia. In de mainstream zit het nu vrijwel overal — Dart kreeg het in versie 3, in 2023. Wat veranderde, is niet het idee maar het argument ervoor. Algebraïsche datatypes werden verkocht op elegantie, en esthetische argumenten verliezen het van sprints. Het argument nu is dat code sneller geschreven wordt dan gelezen, dat de enige reviewer die de generatie bijhoudt de compiler is, en dat alles wat je niet in het type uitdrukt, je uitdrukt als hoop.

De herschrijving

Drie stappen, ongeveer twee minuten werk.

Stap één: schrijf de states in woorden op, vóór er code is. Het scherm laadt. Het scherm is mislukt. Het scherm heeft een lijst. Dat is het — drie. Kom je bij deze stap op zeven uit, dan schrijf je vrijwel zeker vlagcombinaties op in plaats van states. Een state is iets wat je met één woord kunt benoemen en op een mockup kunt aanwijzen.

Stap twee: elke state wordt zijn eigen klasse.

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;
}

Kijk wat er gebeurde. Failed draagt een fout, en die is niet nullable — de state "we zijn mislukt maar er is geen fout" bestaat niet meer, en je kunt hem fysiek niet construeren. Loaded draagt een lijst, ook niet nullable. Loading draagt niets, want tijdens het laden is er geen data, en een veld ervoor openhouden is precies de uitnodiging tot een onmogelijke state.

Let op wat er niet staat. Geen enkele boolean. Geen enkel nullable veld. Dat is de hele refactor; de rest is syntaxis.

Eén Dart-specifiek detail: sealed beperkt afgeleide types tot dezelfde library, en dat is in de praktijk hetzelfde bestand, tenzij je het bewust opsplitst met part. Dat is geen muggenzifterij maar het mechanisme: om volledigheid te controleren heeft de compiler de complete lijst met gevallen nodig, en dat kan hij alleen binnen de library garanderen. Vandaar de afspraak: hoeveel gevallen je ook hebt, ze wonen samen in één bestand. De eerste keer ziet het er vreemd uit en daarna blijkt het handig: de hele stateruimte van het scherm is in één oogopslag zichtbaar.

Stap drie: de UI.

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

Geen enkele if. Geen enkele null-check. De data wordt in het patroon gedestructureerd — :final items — en komt al getypeerd binnen. Binnen de Loaded-tak bestaat de lijst, in plaats van dat hij zou kunnen bestaan. Vergelijk dat met wat je met vlaggen zou hebben: een keten van controles waarvan de volgorde ertoe doet en waarvan niemand de reden nog weet.

Hoe je dit doet in een project met veertig schermen

Herschrijf niet alles. De regel: nieuw scherm, meteen sealed; oud scherm, als je er toch al iets aan het repareren bent. Een scherm dat niemand in twee jaar heeft aangeraakt, laat je met rust. Er zitten waarschijnlijk onmogelijke states in, maar die zijn ofwel al afgegaan, ofwel gaan ze dat nooit doen.

Voor de schermen die je wél aanraakt, komt de prioriteit neer op één ding: op hoeveel plekken die state gelezen wordt. Eén switch in één widget: herschrijven loont nauwelijks, want je ziet toch alles. Een state die op zes plekken gelezen wordt — UI, analytics, logging en een pushafhandelaar — is waar volledigheid zichzelf terugverdient, want dat zijn precies de vijf van de zes die vergeten worden. Loopt de state van een scherm al door één enkele stream in plaats van door een piramide van vlaggen, dan is dat de goedkoopste plek om te beginnen.

Hetzelfde type in vier andere talen

Niet omdat je een tweede taal nodig hebt, maar zodat duidelijk is dat dit geen Dart-dialect of een mode uit de Flutter-gemeenschap is. Hetzelfde type, drie states, data aan de state vast.

Kotlin.

sealed interface ProductsState

data object Loading : ProductsState

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

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

Wordt when als expressie gebruikt, dan moet het elke tak dekken — precies onze switch.

Rust. Interessanter, want dit is de ingebouwde enum:

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

In Rust is een enum vanaf het begin een som met data, zonder extra ceremonie. Mis je een geval in een match, dan compileert het niet.

Swift, hetzelfde met associated values:

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

En de exoot: Idris — de ML-syntaxis waar al het bovenstaande van afstamt:

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

Kijk naar de verticale streep. Die leest als "of". Het somtype wordt letterlijk met een of-symbool geschreven, en daar komt de naam vandaan: dit is een som lang voordat iemand het een sealed class noemde.

Idris doet daarnaast iets wat geen van de andere vier kan:

import Data.Vect

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

Vect (S n) in het type betekent een lijst die gegarandeerd minstens één element heeft. Niet "we hebben het gecontroleerd", niet "we hebben het afgesproken" — je kunt Loaded eenvoudigweg niet met een lege lijst construeren. Onthoud dit fragment, het komt aan het eind terug.

Eén idee, vijf talen, overal volledigheidscontrole. Werk je niet met Dart, dan werkt alles hieronder net zo goed voor jou.

Volledigheid, waar dit allemaal voor was

Een maand later komt er een eis binnen: als de lijst leeg terugkomt, toon dan een placeholder met een knop. Dat is geen fout en geen succes-met-data. Het is een vierde state.

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

Eén regel. Er is verder niets veranderd. En het project compileert niet:

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

De compiler somt elke plek op waar de nieuwe state niet wordt afgehandeld. Niet één ervan — allemaal.

Even waarom dit in een artikel over door AI geschreven code thuishoort: "Voeg een lege state toe aan het lijstscherm" is precies het soort taak dat mensen zonder te kijken aan een agent geven. De agent voegt de klasse toe, werkt de switch bij die hij in zijn context had, en werkt de andere twee niet bij — die in de analytics en die achter de vernieuwknop. Ze stonden niet in de prompt, dus wat hem betreft bestaan ze niet.

Met vlaggen gaat dat stilzwijgend live. Er is geen diff die het opvangt, want de diff bevat alleen wat de agent veranderde. De tests zijn groen, want een state die een maand geleden niet bestond heeft per definitie geen tests. Je hoort het van een gebruiker, zes weken later, verwoord als "soms krijg ik gewoon een leeg scherm".

Met sealed classes gaat het nergens heen. Het compileert gewoon niet.

En dit is het deel dat het meenemen waard is. Volledigheid is geen bescherming tegen AI. Het is feedback ervoor. Waar een agent slecht in is, is dit: hij heeft geen toegang tot het lijstje "en vergeet dit stukje hier ook niet" in jouw hoofd. De compiler geeft dat lijstje machineleesbaar af, met bestanden en regelnummers. De agent loopt tegen rood aan en repareert alle drie, en dat kan hij oprecht goed, want hem is precies verteld waar. Je houdt hem niet tegen — je geeft hem wat hij miste. Hetzelfde mechanisme helpt een mens één keer per week en een agent elke twintig minuten.

De ene regel die het allemaal uitzet

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

Eén underscore en de volledigheidscontrole is dood. Stilzwijgend, zonder waarschuwing. Een vangnettak handelt per definitie alles af, dus er blijft niets voor de compiler over om te controleren. Voeg een vijfde state toe, een zesde, een tiende — het compileert, en valt hier stilzwijgend doorheen.

Neem je precies één reviewregel mee uit dit artikel, neem dan deze:

Een switch over een sealed type hoort geen default-tak te hebben.

Het is de enige regel hier die je met het oog zóú moeten controleren — en een lintregel vangt hem, zodat je dat niet eens hoeft te doen.

Waar algebraïsche datatypes niet helpen

Vier plekken waar ze niet werken of tegen je werken. Zonder deze sectie is de rest een preek.

Een som repareert geen product. Je hebt het scherm in drie states gesplitst, en dat is goed. Maar Loaded is nog steeds een gewone klasse met velden, en zijn dat er twaalf waarvan de helft nullable, dan heb je het moeras alleen een verdieping lager gelegd. Algebraïsche datatypes gaan over welke states bestaan, niet over wat er binnen een state zit.

States die elkaar overlappen — de meest gemaakte fout bij de overstap. Er komt een eis binnen voor pull-to-refresh: de lijst staat al op het scherm en je haalt een verse op. Is dat Loading of Loaded? Het naïeve antwoord is een Refreshing-geval met items erin. Een week later heb je Refreshing, RefreshingAfterError en FailedButHasCache — dezelfde combinatorische explosie, nu in klassen, en dat is merkbaar erger dan vlaggen omdat het omslachtiger is.

Het juiste antwoord bevalt mensen niet:

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

Ja, de boolean is terug, en dat is prima. Hij woont nu binnen een state waar hij iets betekent: "we hebben een lijst en we zijn aan het verversen" is een echte situatie die je moet kunnen uitdrukken. En "verversen zonder lijst" bestaat niet meer, want er is geen plek meer om het neer te zetten.

Sommen voor wat elkaar uitsluit, producten voor wat samen voorkomt

Dit is de regel die bepaalt of de refactor helpt of schaadt. Twee dingen die nooit tegelijk waar kunnen zijn, vragen om een som: aparte gevallen van een sealed type. Twee dingen die routinematig tegelijk waar zijn, vragen om een product: velden naast elkaar binnen één geval. Die twee omdraaien is de belangrijkste manier om slechter uit te komen dan waar je begon, want elkaar uitsluitende states gemodelleerd als parallelle booleans geven je het probleem van de acht combinaties, en samen voorkomende feiten gemodelleerd als aparte gevallen geven je een klasse per combinatie.

Types houden niet elk soort invariant vast, en dit telt zwaarder dan de rest. Een sealed class drukt een structurele invariant prachtig uit: welke states bestaan en welke data met welke state meereist. Over waarde-invarianten zegt hij niets: deze lijst is gesorteerd, de startdatum ligt vóór de einddatum, de regels tellen op tot het totaal, deze string is een geldig e-mailadres. Dat alles wordt nog steeds door een mens vastgehouden, en hoeveel sealed je er ook tegenaan gooit, dat verandert niets.

Dat Idris-fragment is precies het tegenvoorbeeld. Vect (S n) — niet leeg, in het type — is een waarde-invariant die het typesysteem in is geduwd. Dat heet dependent types, en je zou "gesorteerd" en "start vóór eind" op dezelfde manier kunnen uitdrukken. De prijs is een taal waarin je niet schrijft en, realistisch gezien, niet gaat schrijven. In de praktijk grijp je naar een andere techniek: een private constructor met validatie, zodat het type niet in een ongeldige vorm gemaakt kan worden.

In dezelfde categorie zitten de systeemgrenzen. JSON die binnenkomt is niet getypeerd. Algebraïsche datatypes beginnen na het parsen, en een sealed class redt je niet wanneer de backend succes stuurt waar je code success verwacht — expliciete deserialisatie met sleutels die je met de hand schrijft wel. De zin die het bewaren waard is: algebraïsche datatypes elimineren onmogelijke combinaties van states, niet onmogelijke waarden.

En overengineering. Een sealed class met twee gevallen zonder data is een enum, en de enum leest sneller. Een sealed class met één geval is een klasse. Een eerlijke boolean blijft een eerlijke boolean: isSelected op een checkbox is precies twee states, allebei geldig, en een algebraïsch datatype verbetert daar niets. De toets is simpel: tel de combinaties en streep de zinloze weg. Valt er niets weg te strepen, laat het dan met rust.

Tot slot de eerlijke prijs: het is omslachtig. Vier states in Dart is zo'n dertig regels tegenover vier velden. De helft van het bezwaar — dat het trager schrijft — overleeft 2026 niet: dat is precies het werk dat een agent in seconden en zonder fouten doet. De andere helft, dat het trager leest, geldt onverminderd, en dat is de prijs die je betaalt.

Vind ze in je eigen code, in een half uur

Een stateklasse reviewen

Tien controles in drie groepen. Alles wat niet aangevinkt is, is een besluit dat nog niemand heeft genomen.

Het type zelf

  • Elkaar uitsluitende situaties zijn aparte gevallen van een sealed type, geen parallelle booleans
  • Elk veld binnen een geval is niet-nullable, of de null betekent iets specifieks en dat is vastgelegd
  • Data hoort bij de state die haar bezit, niet naast een status-enum
  • Feiten die samen voorkomen zijn velden binnen één geval, geen geval per combinatie

Elke switch erover

  • Geen default-tak en geen underscore-patroon over een sealed type
  • Geen geforceerd uitpakken van een veld dat de state juist hoort te garanderen
  • Elke lezer van de state is gedekt, inclusief analytics, logging en achtergrondafhandelaars

Wat types niet voor je doen

  • Waarde-invarianten — gesorteerd, niet leeg, binnen bereik, welgevormd — worden in een constructor afgedwongen, niet aangenomen
  • Parsen op de systeemgrens valideert expliciet in plaats van op de vorm van de data te vertrouwen
  • Asserts gelden als debughulpmiddel, want in releasebuilds draaien ze niet

De korte versie

Een onmogelijke state is er een die je type toelaat en je domein verbiedt, en dat is hetzelfde als een gebroken invariant. Duur is hij niet omdat er tijdens runtime iets stukgaat, maar omdat elke afspraak die niet in het type staat door iemand opnieuw wordt afgeleid bij elke leesbeurt.

De invariant bestaat altijd. De enige vraag is of een mens hem vasthoudt — met een commentaar, een wikipagina en een assert die in release niet draait — of dat er niets vast te houden valt omdat je hem niet kúnt breken: dat compileert niet.

Drie velden die altijd samen voorkomen, acht combinaties, drie daarvan zinvol. Een sealed class met niet-nullable velden houdt precies die drie over en maakt de andere vijf onbouwbaar. Volledigheid is de opbrengst: die verandert "iemand vergat de afhandeling bij te werken" in "het project compileert niet", en werkt hetzelfde ongeacht wie het vergat. En een default-tak zet dat allemaal uit, stilzwijgend.

En dan de grotere verschuiving. Types waren vroeger de manier waarop je je denkwerk uitlegde aan een collega die het bestand een half jaar later opende. Nu zijn ze de enige manier om het uit te leggen aan iets dat sneller code schrijft dan jij kunt lezen. Het vak is niet verdwenen — het is een niveau omhooggegaan. Vroeger schreef je de implementatie. Nu schrijf je de grenzen waarbinnen de implementatie moet blijven, en invullen kan iedereen. Een machine incluis.

Veelgestelde vragen

Een state die je type toelaat en je domein verbiedt. Een schermstateklasse met een boolean voor laden, een nullable lijst en een nullable fout heeft acht combinaties, maar slechts drie daarvan beschrijven een situatie waarin het product werkelijk kan verkeren. De overige vijf compileren, komen door de review en gaan naar productie. Hetzelfde idee heet preciezer een gebroken invariant: de invariant is de regel die altijd moet gelden, en de onmogelijke state is wat je krijgt wanneer het type die niet afdwingt.
Een manier om te beschrijven hoeveel waarden een type kan aannemen. Een producttype is een en-type: een gewone klasse met velden, waarvan het aantal waarden het product is van de aantallen per veld, zodat elk toegevoegd veld vermenigvuldigt. Een somtype is een of-type: de waarde hoort bij het ene geval of het andere en een derde optie bestaat niet, dus de aantallen worden opgeteld in plaats van vermenigvuldigd. Een enum is een som zonder data, een sealed class is een som waarin elk geval eigen data draagt. Het nut van sommen is dat je geen waarde kunt construeren die een state voorstelt die nooit geldig was.
Omdat het de rekensom verslechtert. Een enum met drie waarden naast een nullable lijst en een nullable fout geeft twaalf combinaties in plaats van acht, en nog steeds betekenen er maar drie iets. Het oorspronkelijke probleem blijft: de status kan success zijn terwijl de lijst null is, dat compileert, en de UI eindigt met het geforceerd uitpakken van het veld. Een enum vertelt je in welke state je zit maar laat de data ernaast liggen, alleen door afspraak aan de state gekoppeld. Een sealed class koppelt de data aan de state, zodat de lijst precies bestaat waar hij iets betekent.
In dezelfde library, en dat is in de praktijk hetzelfde bestand tenzij je het bewust opsplitst met part. Dat is het mechanisme, geen stijlregel: om volledigheid te controleren heeft de compiler de complete lijst met subtypes nodig, en volledigheid kan hij alleen binnen de library garanderen. De afspraak die daaruit volgt is om alle gevallen van een state samen in één bestand te houden, en dat blijkt handig: de hele stateruimte van een scherm is in één oogopslag zichtbaar.
Omdat een vangnettak per definitie elk geval afhandelt, zodat er voor de compiler niets te verifiëren overblijft. Zodra een switch over een sealed type een default-tak of underscore heeft, compileert het toevoegen van een vijfde of tiende state schoon en valt het stilzwijgend in die tak in plaats van de build te laten falen. Dit is de waardevolste reviewregel over het onderwerp: een switch over een sealed type hoort geen default-tak te hebben, en een lintregel kan dat afdwingen.
Ja, maar niet als barrière. Volledigheid is eerder feedback dan bescherming. Vraag je een agent om een nieuwe state toe te voegen, dan werkt hij de switch bij die hij in zijn context had en mist hij de switches die er niet in zaten, want die stonden niet in de prompt. Met vlaggen gaat dat stilzwijgend live, want de diff toont alleen wat er veranderde en een gloednieuwe state heeft geen tests. Met een sealed type faalt de build en noemt de compiler elke onafgehandelde plek met bestand en regelnummer, en dat is precies de invoer waar een agent goed mee omgaat.
Op vier plekken. Ze repareren geen geval dat zelf een opgeblazen producttype met twaalf nullable velden is. Ze schaden wanneer ze gebruikt worden voor states die elkaar werkelijk overlappen, zoals verversen terwijl de data al op het scherm staat, wat om een boolean binnen het geladen geval vraagt in plaats van om een nieuw geval. Ze zeggen niets over waarde-invarianten zoals gesorteerd, niet leeg of een geldig e-mailadres, waarvoor nog steeds een validerende constructor nodig is. En ze beginnen na het parsen, dus op de systeemgrens — waar de backend een verkeerd gespelde statusstring stuurt — doen ze niets.

Wil je de bredere lijst van wat agenten zonder toezicht met Flutter-code doen, dan is dit nummer drie in onze uitsplitsing van zeven punten, en het proces waarmee we het voorkomen staat beschreven in hoe we met AI bouwen. Dit artikel hoort bij onze video-aflevering over hetzelfde onderwerp; de vorige was je SSL pinning werkt waarschijnlijk niet.

Doe de oefening en vertel ons hoeveel rijen je hebt weggestreept en op welk scherm. Onze inzet is dat de meeste mensen op dezelfde drie states uitkomen als wij.


Ilya Nixan is oprichter en lead developer bij Nerdy Production, een Flutter-eerst bureau dat apps bouwt en onderhoudt in fintech, zorg en retail. We doen ook AI-codeaudits voor teams waarvan de codebase sneller groeide dan iemand kon reviewen.