Optional fields multiply states nobody designed
The usual shape of a domain record in a service is one required discriminator and a drift of optional fields hung off it:
interface Payment {
status: "pending" | "settled" | "failed";
settledAt?: Date;
providerRef?: string;
failureCode?: string;
}
Three statuses and three independent optional fields describe twenty-four representable records, of which perhaps three are meaningful. A settled payment with no providerRef type-checks. So does a failed payment carrying a settledAt. Every consumer then compensates with if (p.settledAt) guards whose real purpose is not to handle a case but to persuade the compiler, and the guard that gets forgotten becomes a production undefined.
A discriminated union collapses the twenty-four down to three, because each variant carries exactly the fields that exist when it holds:
Narrowing on p.status does the work the optional-field version pushed onto the reader. The literal type of the discriminant is what makes this possible, so the tag has to be a literal union rather than string.
never as a build-time alarm
The default branch is where the design earns its keep over the following months:
function assertNever(x: never): never {
throw new Error(`unhandled payment variant: ${JSON.stringify(x)}`);
}
Inside default, TypeScript has narrowed p to never if and only if every variant is handled above. Add a fourth { status: "refunded"; ... } member and p is now { status: "refunded"; ... } in that branch, which is not assignable to never, so the call fails to compile. The compiler has turned "find every place that switches on payment status" into a list of build errors, which is the difference between a union you can extend confidently and one you cannot.
Write it as a function that throws rather than leaving the branch empty. Compile-time exhaustiveness assumes the value really is a Payment, and a value that arrived unvalidated from a queue can carry a status string outside the union at runtime. That is the case the throw catches.
Ids that cannot be swapped
Structural typing means UserId declared as type UserId = string is just string, and every function taking two ids accepts them in either order. Branding adds a phantom property that exists only in the type:
At runtime a UserId is still a plain string with no wrapper cost, and it keeps every string method. What changes is that cancelOrder(orderId, userId) is now a compile error where two bare strings were indistinguishable, and the only route into the branded type is the one constructor you can audit. Keep that cast confined to the constructor; a codebase that writes as UserId at forty call sites has the annotation without the guarantee.
Two declarations of the same shape will drift
Everything above holds only inside the compiled boundary. Types are erased: tsc emits no checks, so const body = JSON.parse(raw) as CreateOrder is a claim about the request body, not an inspection of it. A field that is absent, a quantity that arrived as the string "2", a currency outside your enum, all pass through and are typed as though they had been verified. The failure then surfaces several layers inside the service, where the stack trace blames code that was correct.
The fix is to parse at the edge, and to declare the shape once. Write the schema and derive the type from it:
The point of z.infer is not brevity. If you hand-write an interface CreateOrder next to the schema, the two are separate declarations of the same contract maintained by hand, and the first time someone adds a field to only one of them you get a validator that accepts data the type says cannot exist, or a type promising a field nothing checks. Inference makes that class of bug unrepresentable too. The same reasoning applies outward: validate responses from third-party APIs, environment variables, and anything read off a message bus, because each of those is unknown wearing a type you wrote from documentation.
Say where the boundary is, and stop there. Parse once on the way in, hand the parsed and branded value inward, and let the internal layers trust it. Re-validating in the service layer is a signal that nobody is sure who owns the check, and that uncertainty is what eventually produces the layer where nobody checks at all.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).