TypeScript That Models Reality Instead of Fighting It

April 28, 2026

TypeScript That Models Reality Instead of Fighting It

TypeScript is at its best when the types describe what the data can genuinely be, and at its worst when it becomes a second program you maintain alongside the first.

The difference usually comes down to one habit: make illegal states unrepresentable, then let inference carry the rest.

TypeScript code on a screen

Optional fields describe more states than you mean

This is the most common modelling mistake, and it looks entirely reasonable:

interface RequestState { loading?: boolean; data?: User; error?: string; }

Three optional fields describe eight combinations. Exactly three of them are meaningful. The type permits loading: true alongside a populated data and an error, and every consumer has to defend against combinations that can never occur — usually with a chain of checks that quietly encodes assumptions the type never made.

A discriminated union says the same thing honestly:

type RequestState = | { status: "loading" } | { status: "success"; data: User } | { status: "error"; error: string };

Now data exists precisely when it can exist. The compiler narrows on status, and the impossible cases stop being reachable:

function render(state: RequestState) { switch (state.status) { case "loading": return <Spinner />; case "success": return <Profile user={state.data} />; // data is defined here case "error": return <Error message={state.error} />; } }

Add a fourth state later and every switch that fails to handle it becomes a compile error. The type system starts doing the work of finding your call sites.

Let inference do its job

Annotating what TypeScript already knows adds maintenance without adding safety:

// Redundant: the annotation restates the literal. const users: User[] = await fetchUsers(); const isActive: boolean = user.status === "active"; // The inferred types are identical, and stay correct when the source changes. const users = await fetchUsers(); const isActive = user.status === "active";

Annotate the boundaries — function parameters, exported return types, anything crossing a module edge — and let the interior infer. Boundary annotations are documentation and they catch real mistakes. Interior ones mostly go stale.

as const is worth reaching for when you want literals preserved rather than widened:

const ROLES = ["admin", "editor", "viewer"] as const; type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer"

One array, and the type follows it. Adding a role updates both.

any is not the only escape hatch

any switches off checking and lets errors propagate silently into code that had nothing to do with the original problem. unknown keeps the value opaque but forces a check before use:

function handleResponse(payload: unknown) { if (typeof payload === "object" && payload !== null && "id" in payload) { // Narrowed by inspection rather than by assertion. } }

The same applies to as. A type assertion is a claim the compiler accepts without evidence — fine when you genuinely know more than it does, and a silent lie the rest of the time. Prefer a check that proves the claim.

Keep types readable

Conditional types and mapped types are powerful and easy to over-apply. A type nobody on the team can read is a type nobody will maintain correctly, and the cleverness is rarely load-bearing.

Some cheap heuristics:

  • If a type needs a comment to explain what it produces, consider writing it out longhand.
  • Name intermediate types instead of nesting three transformations in one expression.
  • Prefer a union you can read over a generic that computes the same union.

Wrap-up

Good TypeScript reads like accurate documentation that happens to be enforced. Model the states your data can really be in, annotate the edges and infer the middle, and reach for unknown before any.

Do that and the compiler stops being an obstacle and starts being the first reviewer.

GitHub
LinkedIn
Instagram