Kijk lang genoeg naar een type en je begint te zien hoeveel toestanden het kan representeren die nooit zouden mogen voorkomen.
Een order die Pending is maar een PaidAt-timestamp heeft. Een order die Refunded is zonder reden voor de terugbetaling. Een RefundedAt die gezet is terwijl PaidAt nog null is — terugbetaald voordat er ooit betaald werd.
Geen van deze hoort te bestaan. En toch laat het type je vrolijk elk ervan bouwen. Dus vult de codebase zich met guard clauses die controleren op toestanden waarvan het domein zegt dat ze niet kunnen voorkomen — en elke check is een plek waar iemand het kan vergeten of fout kan doen.
Make illegal states unrepresentable is de oplossing: ontwerp het type zó dat de foute combinaties überhaupt niet te construeren zijn. Vorige week wees ik Claude Code op precies zo’n type, en het interessante was niet het principe — het was hoeveel van het werk mechanisch bleek te zijn.
De uitgangssituatie
Hier is het model. Een status-enum, plus de velden die alleen in sommige van die statussen zin hebben:
public class Order
{
public Guid Id { get; set; }
public OrderStatus Status { get; set; }
public DateTimeOffset? PaidAt { get; set; }
public DateTimeOffset? RefundedAt { get; set; }
public string? RefundReason { get; set; }
}
public enum OrderStatus { Pending, Paid, Refunded }
Het leest prima. Elke echte order past erin. Het probleem is alles anders dat er óók in past.
Laat Claude Code de toestanden tellen
Ik gaf het het type en één vraag:
“Hoeveel verschillende toestanden kan dit
Order-type representeren, en hoeveel daarvan zijn in het domein daadwerkelijk legaal? Een pending order heeft geen betaling; een paid order heeft eenPaidAt; een refunded order heeft eenPaidAt, eenRefundedAten een reden.”
Het antwoord was de kern van de hele oefening. Drie enum-waarden, vermenigvuldigd met het wel-of-niet-aanwezig zijn van drie nullable velden, geeft 3 × 2 × 2 × 2 = 24 representeerbare toestanden. Daarvan zijn er precies drie legaal:
Pendingmet alle drie de velden nullPaidmetPaidAtgezet, de rest nullRefundedmet alle drie gezet
De andere eenentwintig zijn combinaties die de compiler je laat bouwen en die het domein verbiedt. Elke guard clause in de codebase bestaat om zich tegen één van die eenentwintig te verdedigen. Dat is geen modelleerdetail — dat is de bron van een hele categorie bugs.
De illegale toestanden onrepresenteerbaar maken
De oplossing is om te stoppen met “een status en wat misschien-velden” modelleren en te beginnen met “één van drie specifieke vormen”. In C# is dat een gesloten hiërarchie van records:
public abstract record Order
{
public required Guid Id { get; init; }
public sealed record Pending : Order;
public sealed record Paid(DateTimeOffset PaidAt) : Order;
public sealed record Refunded(
DateTimeOffset PaidAt,
DateTimeOffset RefundedAt,
string Reason) : Order;
// Private constructor: alleen de geneste records hierboven kunnen
// van Order erven, dus deze drie zijn de enige mogelijke vormen.
private Order() { }
}
Nu zijn de illegale toestanden weg — niet afgevangen, weg. Er is geen manier om een Refunded-order zonder reden te construeren, want Reason is een constructorparameter. Er bestaat geen Pending-order met een verdwaalde PaidAt, want Pending heeft dat veld niet. De eenentwintig onzintoestanden zijn niet te typen.
De private constructor verzegelt de hiërarchie: omdat de records genest zijn in Order, kunnen ze de private constructor aanroepen, maar niets daarbuiten. Dus Pending, Paid en Refunded zijn de enige orders die ooit kunnen bestaan.
Wat de compiler nu voor je doet
Consumerende code switcht op de vorm in plaats van velden te ondervragen:
var label = order switch
{
Order.Pending => "Awaiting payment",
Order.Paid p => $"Paid on {p.PaidAt:d}",
Order.Refunded r => $"Refunded: {r.Reason}",
};
Geen null-checks. Geen if (order.Status == Refunded && order.RefundReason is null)-verdedigingstak — die toestand kan deze code niet bereiken.
Een switch als deze is bovendien pure beslislogica: geen database, geen klok, niets om te mocken. Precies het soort code dat thuishoort in een functional core, met de effecten naar de shell geduwd.
Eén eerlijke kanttekening over C#: anders dan bij een enum — of een discriminated union in F# of Rust — behandelt de compiler een type-hiërarchie niet als gesloten. Hij kan niet zien dat Pending, Paid en Refunded de enige vormen zijn, dus deze switch compileert eigenlijk mét een CS8509-waarschuwing (“niet uitputtend”) tenzij je een default-tak toevoegt. Dat is minder erg dan het klinkt: laat de default-tak bewust weg, en die waarschuwing wordt een blijvende nudge om elke switch opnieuw te bekijken zodra het model groeit. Wil je liever een _ => throw new ...-tak, dek die dan af met een test die controleert of elke vorm afgehandeld wordt, want de compiler doet dat niet meer. Hoe dan ook zit de echte garantie in het type, niet in de switch — de eenentwintig illegale toestanden bereiken deze code sowieso nooit.
De functional-programming-connectie
Als je met F#, Rust of een andere taal met algebraïsche datatypes hebt gewerkt, komt dit patroon je al bekend voor. Een waarde is precies één van een vaste set vormen, en elke vorm draagt alleen de data die bij dat geval hoort. Dat is wat de record-hiërarchie ons opleverde: Pending, Paid en Refunded zijn geen status met een zak optionele velden meer, maar drie afzonderlijke varianten van één concept.
C# heeft vandaag geen native discriminated unions, dus de gesloten hiërarchie is een benadering. De private base-constructor geeft je de garantie die telt — alleen de legale vormen zijn te bouwen — maar de compiler bewijst nog steeds niet dat je matches volledig zijn, en dáárom verscheen die CS8509-waarschuwing eerder.
Dat gat wordt gedicht. Het C#-taalontwerpteam heeft een champion-proposal voor union-types, en Microsoft publiceert er al een preview feature-specificatie voor. De kernonderdelen — union-types, closed enums en closed hierarchies — zijn in principe goedgekeurd op de design meetings, dus dit is een kwestie van wanneer, niet of. Er is nog geen toegezegde versie, maar een toekomstige C# laat je het hele ding in één regel schrijven:
public union Order(Pending, Paid, Refunded);
Met een echte union weet de compiler dat de set gesloten is, dus de switch wordt bewijsbaar uitputtend — de CS8509-waarschuwing verdwijnt, en een variant toevoegen maakt elke niet-afgehandelde switch automatisch een fout. Precies het F#/Rust-gedrag, ingebouwd in de taal.
Totdat dat er is, kom je vandaag al een heel eind. In plaats van élke waarschuwing tot fout te promoveren, promoveer je alleen dat ene diagnostisch:
<PropertyGroup>
<WarningsAsErrors>$(WarningsAsErrors);CS8509</WarningsAsErrors>
</PropertyGroup>
Nu laat een niet-uitputtende switch de build falen. Voeg een vierde variant toe — zeg Cancelled — en elke switch die hem niet afhandelt stopt met compileren totdat ik hem fix. Het is een waarschuwingsbeleid in plaats van een echte gesloten union, maar in de praktijk is het hetzelfde vangnet.
En het is precies het soort wijziging dat ik aan Claude Code geef. Nadat ik een variant heb toegevoegd, vraag ik het om “elke switch op de Order-hiërarchie te vinden en de ontbrekende tak toe te voegen.” De compiler vertelt me welke fout zijn; Claude Code fixt ze in één keer. Ik modelleer het domein als een set expliciete alternatieven — de machine houdt elke match eerlijk.
De andere hefboom: primitive obsession
Hetzelfde principe geldt een niveau lager, voor de velden zelf. string Reason laat je nog steeds "" doorgeven. decimal Amount laat je nog steeds een negatief getal doorgeven. Ze verpakken in kleine value objects met een validerende factory duwt ook die illegale waarden eruit:
public readonly record struct RefundReason
{
public string Value { get; }
private RefundReason(string value) => Value = value;
public static Result<RefundReason> Create(string value) =>
string.IsNullOrWhiteSpace(value)
? Result.Failure<RefundReason>("Refund reason is required.")
: Result.Success(new RefundReason(value.Trim()));
}
De Result<T> hier is gewoon een success-of-failure-wrapper (uit CSharpFunctionalExtensions, of je eigen versie). Nu glipt een lege reden niet meer langs Create — al omzeilt default(RefundReason) de factory nog steeds met een null Value, omdat dit een struct is; maak er een sealed record van als je dat gaatje ook wilt dichten. Dit is hetzelfde idee als parse, don’t validate: valideer één keer aan de rand, en draag daarna een type dat bewijst dat de waarde goed is, in plaats van hem overal opnieuw te controleren.
Waar Claude Code hier echt goed in is — en waarin niet
Het herontwerp zelf is een afweging, en dat deel bleef bij mij. Beslissen dat er precies drie legale vormen zijn, dat een terugbetaling altijd een reden heeft, dat je niet kunt terugbetalen vóór er betaald is — dat is domeinkennis. Claude Code stelde een model voor, maar ik ben degene die de businessregels kent, dus ik ben degene die tekent.
Alles ná die beslissing was mechanisch, en dáár verdiende de AI zijn geld:
- De toestanden tellen. 24 representeerbare toestanden opsommen en de 21 illegale markeren is met de hand saai en foutgevoelig. Claude Code deed het in één keer en gaf me een tabel om te controleren.
- De consumers herschrijven. Elke plek die
if (order.Status == OrderStatus.Paid)deed, moest eenswitch-tak worden. In een echte codebase zijn dat tientallen call sites. Ik vroeg Claude Code om “elke consumer vanOrder.Statusom te zetten naar een switch expression op de nieuwe record-hiërarchie, en elke tak op te sommen die niet meer type-checkt.” - De nu-dode guards vinden. Het hele punt was om verdedigende code te verwijderen. Claude Code is goed in het spotten van
if (x is null) throw-checks die beschermen tegen een toestand die het nieuwe type niet kan produceren, en die markeren voor verwijdering.
Het patroon is steeds hetzelfde, en de reden dat mijn werk meer op architectuur dan op typen lijkt: ik neem de ontwerpbeslissing, Claude Code doet de fan-out.
Tel de illegale toestanden
Zoek een class in je codebase met een status-enum en twee of meer nullable velden die alleen op sommige van die statussen van toepassing zijn. Het is een veelvoorkomende vorm — orders, abonnementen, documenten, alles met een levenscyclus.
En voordat je iets refactort, zet je Claude Code erop en laat je het tellen:
“Hoeveel verschillende toestanden kan dit type representeren, en hoeveel zijn er in het domein daadwerkelijk legaal? Som de illegale combinaties op.”
Lees de illegale. Als die lijst langer is dan de legale — en dat is hij meestal — heb je een type gevonden dat je hele team al die tijd om voorzichtigheid heeft gevraagd, terwijl het de fouten net zo goed onmogelijk had kunnen maken.
Reacties