Skip to content
QSWEQB
mediumConceptDesignMidSeniorStaff

How do you use TypeScript's type system to make invalid states unrepresentable in a service?

You shrink the set of values the compiler accepts: discriminated unions instead of optional-field soup, branded types so one id cannot stand in for another, and a never-typed fallback that fails the build when a variant is added. Types vanish at runtime, so input is parsed by a schema the type is inferred from.

4 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate count the illegal combinations that optional fields create, rather than treating optionality as a stylistic choice
  • Whether the exhaustiveness argument is made in terms of what breaks the build when a variant is added, not just that switch statements are tidy
  • That they distinguish a type assertion from a runtime check, and know the compiler emits nothing to enforce either
  • Whether the schema is treated as the single declaration with the type inferred from it, instead of an interface maintained alongside it
  • Does the candidate say where validation stops, so internal code is not defensively re-checking values already parsed at the edge

Answer

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:

type Payment =
  | { status: "pending"; requestedAt: Date }
  | { status: "settled"; settledAt: Date; providerRef: string }
  | { status: "failed"; failureCode: string; retryable: boolean };

function receipt(p: Payment): string {
  switch (p.status) {
    case "pending":
      return `awaiting since ${p.requestedAt.toISOString()}`;
    case "settled":
      // providerRef is string here, not string | undefined - no guard needed.
      return `settled via ${p.providerRef}`;
    case "failed":
      return `failed ${p.failureCode}`;
    default:
      return assertNever(p);
  }
}

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:

declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

// The single place a raw string is allowed to become a UserId.
function toUserId(raw: string): UserId {
  if (!/^usr_[a-z0-9]{16}$/.test(raw)) throw new Error("malformed user id");
  return raw as UserId;
}

declare function cancelOrder(user: UserId, order: OrderId): Promise<void>;

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:

import { z } from "zod";

const CreateOrderSchema = z.object({
  userId: z.string().regex(/^usr_[a-z0-9]{16}$/).transform((s) => s as UserId),
  quantity: z.number().int().positive(),
  currency: z.enum(["GBP", "EUR", "USD"]),
});

// Inferred from the schema. There is no second source of truth to update.
type CreateOrder = z.infer<typeof CreateOrderSchema>;

function handle(body: unknown): CreateOrder {
  const parsed = CreateOrderSchema.safeParse(body);
  if (!parsed.success) throw new Error(JSON.stringify(parsed.error.issues));
  return parsed.data;
}

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.

Likely follow-ups

  • Where exactly do you draw the validation boundary in a service that also reads from its own database?
  • How do branded types survive a JSON round trip through a queue, and what has to re-establish the brand?
  • When is a runtime tagged class hierarchy a better fit than a discriminated union of plain objects?
  • What breaks about exhaustiveness checking if the union is declared in a library you do not control?

Related questions

Further reading

discriminated-unionsbranded-typesexhaustivenesszodtype-safety