Skip to content
fellowcoder
All articles

Types that earn their keep

Most TypeScript adds ceremony without removing bugs. Four patterns that make illegal states unrepresentable instead of merely annotated.

fellowcoder6 min read1,138 words

There is a version of TypeScript that is just JavaScript with extra typing — string here, number there, any where it got annoying. It catches typos. It does not catch bugs.

The version that earns its keep does something different: it makes the states your program should never be in impossible to write down. Not discouraged. Not caught in review. Unrepresentable — the compiler cannot produce a program that contains them.

Four patterns that do most of the work.

1. Discriminated unions over optional soup#

Here is a type that describes a request, and also describes eleven states that should never exist:

before.ts
type RequestState = {
  loading: boolean;
  data?: User;
  error?: Error;
};

What is { loading: true, data: someUser, error: someError }? It typechecks. It means nothing. Every consumer of this type has to handle it anyway, which is why you end up with defensive checks scattered across the codebase:

if (state.loading) return <Spinner />;
if (state.error) return <Error e={state.error} />;
if (!state.data) return null; // when does this happen? nobody knows
return <Profile user={state.data} />;

That if (!state.data) is the tell. It's dead code that the compiler forces you to write because the type admits a state your program never produces.

after.ts
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: Error };

Four states, all meaningful. Narrowing on status gives you data with no optionality, and the impossible combinations are now compile errors rather than runtime branches.

The payoff compounds with exhaustiveness. Add a "refreshing" state later, and every switch that doesn't handle it fails to compile — if you wrote the guard:

exhaustive.ts
function render(state: RequestState) {
  switch (state.status) {
    case "idle":    return null;
    case "loading": return <Spinner />;
    case "success": return <Profile user={state.data} />;
    case "error":   return <ErrorView e={state.error} />;
    default: {
      const _exhaustive: never = state;
      return _exhaustive;
    }
  }
}

That never assignment is the entire mechanism. Every unhandled case becomes a type error pointing at the exact line that needs updating. It costs three lines per switch and turns "did I update everywhere?" from a code search into a build.

2. Branded types for values that share a runtime shape#

userId and orgId are both strings. So are email, slug, and the raw HTML you're about to render. The compiler considers them all interchangeable, which means this typechecks perfectly:

getUser(orgId); // 💥 at runtime, ✅ at compile time

Branding fixes it with zero runtime cost:

branded.ts
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
 
type UserId = Brand<string, "UserId">;
type OrgId = Brand<string, "OrgId">;
 
// The only way in is through a validating constructor.
function userId(raw: string): UserId {
  if (!/^usr_[a-z0-9]{16}$/.test(raw)) throw new Error(`bad user id: ${raw}`);
  return raw as UserId;
}
 
getUser(orgId); // ❌ Argument of type 'OrgId' is not assignable to 'UserId'

At runtime these are plain strings — the brand exists only in the type system, so there's no wrapper allocation and no serialization surprise.

Where this pays off most is anywhere a value has been checked and you want the check to be visible in the type. SanitizedHtml, ValidatedEmail, AbsolutePath, Cents (as opposed to a float of dollars). The type now records not just the shape but the fact that something was verified, and there is exactly one place that verification can happen.

3. Parse, don't validate#

The difference between validation and parsing is what the function returns.

A validator returns a boolean and throws away everything it learned. Downstream code still has the wide type and still has to re-check — or, more commonly, doesn't, and casts:

validate.ts
function isValidUser(x: unknown): boolean { /* ... */ }
 
if (isValidUser(input)) {
  const user = input as User; // the cast is a lie the compiler can't check
}

A parser returns the narrowed type, so the knowledge is preserved in the type system:

parse.ts
import { z } from "zod";
 
const User = z.object({
  id: z.string().regex(/^usr_/),
  email: z.string().email(),
  createdAt: z.coerce.date(),
});
type User = z.infer<typeof User>;
 
// At the boundary — and only at the boundary.
const user = User.parse(await res.json()); // typed User, or it throws

The structural rule that falls out of this: parse at the edges, and only at the edges. HTTP handlers, queue consumers, file readers, third-party API responses. Everything inside those boundaries deals in types that are already correct, and no interior function needs a defensive check.

This is also the answer to defensive programming creep. When every function validates its inputs, every function is admitting it doesn't trust its callers, and you get validation logic smeared across the entire codebase with no single authority. Parse once, at the door.

Takeaway

A validator asks "is this okay?" and forgets the answer. A parser asks "what is this?" and returns something the rest of the program can rely on.

4. Function signatures that refuse bad calls#

Boolean parameters are the most common place where a type is technically correct and practically useless:

createUser(name, email, true, false, true);

Nobody reading the call site knows what those mean. The compiler is satisfied, and swapping two of them is a silent behavior change that no test will necessarily catch.

options.ts
type CreateUserOptions = {
  sendWelcomeEmail: boolean;
  requireVerification: boolean;
  isAdmin: boolean;
};
 
createUser(name, email, {
  sendWelcomeEmail: true,
  requireVerification: false,
  isAdmin: true,
});

Now swapping two is a compile error, and the call site is self-documenting.

The stronger version applies when the parameters are related: instead of three independent booleans producing eight combinations of which four are nonsense, model the four real ones.

modes.ts
type CreateMode =
  | { kind: "self-signup"; sendWelcomeEmail: true }
  | { kind: "admin-invite"; invitedBy: UserId }
  | { kind: "sso-provision"; idpId: string };

Eight combinations became three, and every one of them is a thing that actually happens.

The through-line#

All four patterns are the same move: push what you know into the type, so the compiler enforces it instead of a human remembering to.

The test for whether a type is earning its keep is simple. Delete it and imagine the class of bug that becomes possible. If the answer is "a typo," it's annotation. If the answer is "the impossible state we hit in production last March," it's a type doing its job.

The rest is ceremony, and TypeScript already has plenty of that.