Designing Robust, Maintainable TypeScript Code

Learn a set of practices that hold up in real, growing codebases: trusting inference, modeling domains with discriminated unions, validating data at runtime boundaries, favoring composition over giant interfaces, and designing stable public type contracts.

TypeScript's type system is expressive enough that it's easy to either under-use it (reaching for any whenever something gets slightly tricky) or over-use it (building elaborate generic machinery that's technically clever but painful for teammates to read). Good TypeScript is neither — it's the smallest set of types that accurately models your domain, enforced consistently at the boundaries where untyped data enters your program.

Strict mode, any versus unknown, and trusting inference

Every serious TypeScript project should start with "strict": true in tsconfig.json. It's a bundle of checks — strictNullChecks, noImplicitAny, and several others — that close the specific holes where unsafe code would otherwise silently pass the checker. Retrofitting strict mode onto a large codebase that grew without it is dramatically harder than starting with it on, because the errors it surfaces compound with codebase size.

any should be treated as a last resort, not a convenience — once a value is any, checking stops for it and for everything computed from it, which means a single any can silently erode type safety across an entire call chain. unknown is almost always the better choice for a value whose type you genuinely don't know yet (parsed JSON, a caught error, third-party data): it accepts anything, exactly like any, but forces you to narrow it with a typeof check, instanceof check, or a type guard before you can do anything specific with it.

A less obvious but equally important habit is trusting TypeScript's inference instead of over-annotating. Writing const total: number = price * quantity; when price and quantity are already typed numbers adds noise without adding safety — TypeScript already infers number correctly. Reserve explicit annotations for function parameters (which TypeScript can't infer from nothing), public function return types (worth being explicit for stability, discussed below), and the rare case where inference genuinely picks a wider type than you want.

Modeling domains with discriminated unions

A discriminated union models a value that can be one of several distinct shapes, tagged by a shared literal-type field (conventionally named type or kind) that TypeScript can use to narrow which shape you're looking at. This is the single most effective pattern for representing real-world states — a network request that's loading, succeeded, or failed; a shape that's a circle, square, or triangle; a payment that's pending, completed, or refunded — because it makes invalid states genuinely unrepresentable, rather than merely undocumented.

The alternative — one large interface with a mix of optional fields covering every possible state — is a common anti-pattern precisely because it allows nonsensical combinations to type-check: an object claiming status: "loading" while also carrying a populated data and an error field is invalid in practice but perfectly legal to that interface. A discriminated union makes that combination impossible to construct in the first place.

Once a union is discriminated, a switch on the tag field lets TypeScript narrow each case to its specific shape, and adding a default branch that assigns the value to a variable typed never gives you an exhaustiveness check for free — if a new variant is added to the union later and a switch statement forgets to handle it, that never assignment becomes a compile error, catching the omission immediately rather than at runtime.

Safe assertions and runtime validation at boundaries

The as assertion tells TypeScript 'trust me, this is this type' without any runtime check — it's a way to override the checker, not a way to convert or validate a value. Assertions are appropriate when you genuinely know more than the type checker can infer (narrowing a document.getElementById result you know exists to HTMLInputElement), but they are dangerous when used to silence an error about data whose shape you haven't actually verified, such as an API response cast directly to a domain type.

The non-null assertion operator (value!) is a narrower, equally risky cousin: it tells TypeScript to treat a possibly-null or possibly-undefined value as definitely present, again with no runtime check. A value! on data that turns out to actually be null at runtime doesn't throw a helpful TypeScript-flavored error — it throws whatever ordinary JavaScript error results from operating on null, at a point in the code that gave no indication it was risky.

For any data crossing a genuine trust boundary — an HTTP response body, a form submission, localStorage contents, a webhook payload — an assertion is the wrong tool entirely, because it doesn't check anything at runtime. Runtime validation libraries (zod, io-ts, and similar) instead define a schema once and derive both a TypeScript type and an actual runtime-checking function from it, so malformed data is caught and rejected at the boundary, before it ever propagates into code that trusted it was well-typed.

Composition over giant interfaces and over-engineering

A single sprawling interface — User with forty optional fields covering every feature the app has ever needed from a user object — is a maintenance liability: it's unclear which fields are actually relevant in any given context, it grows without bound as features are added, and it forces every function accepting a User to defensively check for fields it doesn't actually need. Composing several small, focused interfaces (UserIdentity, UserPreferences, UserBillingInfo) and combining them with intersection types or explicit parameters where needed keeps each piece legible and lets functions declare precisely what they depend on.

The opposite failure mode is over-engineering: building elaborate generic, conditional, or mapped types to handle flexibility nobody has actually asked for yet. A generic utility type with five type parameters and nested conditional logic might be an impressive demonstration of what the type system can do, but if it makes error messages unreadable and requires a comment explaining what it does, it has usually traded real-world maintainability for theoretical flexibility. The best types, like the best code generally, are boring — they say exactly what they mean and no more.

A useful rule of thumb: if you can't explain a type to a teammate in one sentence without them needing to trace through several intermediate type aliases, it's a candidate for simplification, even if it technically 'works'.

Public API design, type boundaries, and type-level testing

For any code exposed to other modules or published as a package, the exported types form a public contract exactly as much as the exported functions do — changing an exported interface's shape is a breaking change for consumers even if every internal call site still compiles fine. Being deliberate about what's exported (and keeping internal helper types unexported) keeps that contract small and stable, and is exactly the discipline tools like API Extractor exist to enforce automatically at scale.

Explicit return type annotations on public functions are worth the extra syntax specifically because they act as a boundary: without one, a function's return type is whatever TypeScript infers from its implementation, which means a seemingly harmless internal refactor can silently widen or narrow the public return type and break consumers without a single explicit change to the function's signature ever being reviewed as an API change.

Type-level debugging — figuring out why a type doesn't match what you expected — usually starts with hovering over a variable in the editor to see TypeScript's inferred type directly, or deliberately assigning a value to an intentionally wrong type annotation to force the compiler to print the actual inferred type in its error message as a diagnostic trick. For genuinely testing that types behave as intended over time (not just that the code compiles today), dedicated type-testing tools like tsd or expect-type let you write assertions purely at the type level — 'calling this function with these arguments should produce this exact return type' — that run as part of your test suite and catch accidental type regressions the same way ordinary tests catch behavioral regressions.

A discriminated union with an exhaustive switch

Each branch of the union narrows state to exactly the fields relevant to that status, and the default branch's never assignment fails to compile if a new status variant is ever added without a matching case.

type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; message: string };

function render<T>(state: RequestState<T>): string {
  switch (state.status) {
    case "idle":
      return "Waiting to start";
    case "loading":
      return "Loading...";
    case "success":
      return `Loaded: ${JSON.stringify(state.data)}`;
    case "error":
      return `Error: ${state.message}`;
    default: {
      const _exhaustive: never = state;
      return _exhaustive;
    }
  }
}

Code Examples

response.json() is typed as any by the DOM lib by default, but assigning it to a variable explicitly typed unknown and passing it through a type guard forces real validation before the value is trusted as a Product — a raw as Product cast here would have skipped that check entirely.

if (!isProduct(data)) { throw new Error("Malformed product response"); }

Common mistake: Writing return (await response.json()) as Product; compiles cleanly but performs zero runtime verification — if the API ever returns a differently-shaped error object, this code confidently treats it as a valid Product and the bug surfaces much later, far from its actual cause.

interface Product {
  id: string;
  name: string;
  price: number;
}

function isProduct(value: unknown): value is Product {
  if (typeof value !== "object" || value === null) return false;
  const v = value as Record<string, unknown>;
  return (
    typeof v.id === "string" &&
    typeof v.name === "string" &&
    typeof v.price === "number"
  );
}

async function fetchProduct(url: string): Promise<Product> {
  const response = await fetch(url);
  const data: unknown = await response.json();
  if (!isProduct(data)) {
    throw new Error("Malformed product response");
  }
  return data; // narrowed to Product here
}

Where validation belongs relative to your typed domain model

Validation at the boundary is what makes the type system's promises actually true at runtime -- an as cast at that same boundary only makes the promise on paper.

untrusted external data
  (API response, form input, localStorage, webhook)
           |
     [validation boundary]
   (runtime check: type guard / schema library --
    rejects or throws on malformed data HERE)
           |
     typed domain model
   (Product, User, Order -- trusted from this
    point on, used freely through the app)
           |
     application logic
   (no further defensive checks needed --
    the type system's guarantees are now real)

Important Rules

Enable strict mode on every new project; prefer unknown over any for genuinely uncertain values, and narrow before use.

Model mutually-exclusive states as discriminated unions, not as one interface with a pile of optional fields.

Validate data at every genuine trust boundary with a runtime check or schema, never with a bare as assertion.

Compose small, focused interfaces rather than growing one interface to cover every use case.

Treat exported types as part of your public API — annotate public function return types explicitly, and keep internal helper types unexported.

Where this shows up

Teams that adopt discriminated unions for async/request state (loading/success/error) consistently report fewer 'undefined is not an object' production errors than teams using a single optional-heavy interface, because the type system genuinely prevents rendering a success view with no data or an error view with no message. Similarly, teams that validate API responses with a schema library at the network boundary catch backend contract changes at the moment they happen, in a clear error, instead of as a confusing crash several components downstream.

interface User { id: string; name?: string; error?: string; isLoading?: boolean; data?: unknown; /*...30 more optional fields */ } type UserState = { status: "loading" } | { status: "loaded"; user: User } | { status: "error"; message: string };

The all-optional interface allows combinations that make no real sense (isLoading true and data populated at the same time); the discriminated union makes each valid state explicit and every other combination a compile error.

[multiple-choice] Why is a discriminated union usually better than one interface with many optional fields for representing mutually-exclusive states? A. It uses less memory at runtime B. It prevents constructing combinations that don't make logical sense C. Optional fields are deprecated in TypeScript D. There is no real difference, it's purely stylistic

B. It prevents constructing combinations that don't make logical sense — A discriminated union's variants are mutually exclusive by construction, so invalid combinations (like loading being true while error data is also populated) simply can't be represented, unlike an all-optional interface.

[true-false] Using as SomeType to cast a value performs the same runtime validation as a type guard function. A. True B. False

B. False — as is a compile-time-only assertion with no runtime check; a type guard function actually inspects the value at runtime before narrowing its type, which is why it's the safer choice at trust boundaries.

[multiple-choice] What does assigning a value to a variable typed never inside a switch's default case accomplish? A. It silences all further type errors in the file B. It provides an exhaustiveness check that fails to compile if a union variant is left unhandled C. It deletes the value at runtime D. It has no effect unless strict mode is off

B. It provides an exhaustiveness check that fails to compile if a union variant is left unhandled — If every case of a discriminated union is handled, the value remaining in the default branch is narrowed to never; if a new variant is added later without a matching case, that assignment becomes a type error, catching the gap at compile time.

Model a traffic light as a discriminated union with three states: red, yellow, and green, where the yellow state additionally carries a secondsRemaining: number field the others don't have. Then write a function that returns a human-readable string for each state using an exhaustive switch.

type TrafficLight = | { color: "red" } | { color: "green" } | { color: "yellow"; secondsRemaining: number }; function describe(light: TrafficLight): string { switch (light.color) { case "red": return "Stop"; case "green": return "Go"; case "yellow": return Caution, ${light.secondsRemaining}s remaining; default: { const _exhaustive: never = light; return _exhaustive; } } }

Use a shared color field as the discriminant, and add the never-typed default branch to get an exhaustiveness check.