Idiomatic TypeScript at Scale

Knowing the type system isn't the same as using it well. Idiomatic TypeScript is a set of judgments: lean on inference, avoid `any`, model impossible states out of existence, and know when a type earns its complexity. On a large, long-lived codebase these habits are the difference between types that catch bugs and types that are decorative noise. This closing post turns the mechanics of the series into a working discipline.

We’ve built up the whole type system — structural typing, unions, generics, utility types, async, tooling. This final post is about taste: how to wield it on real codebases so the types pay for themselves. These are the practices that distinguish TypeScript that prevents production incidents from TypeScript that merely adds ceremony.

Let inference work; annotate boundaries

The first habit is knowing where types belong. Annotate the boundaries — function parameters, public API signatures, exported types — and let inference handle the internals. The compiler already knows that const total = price * quantity is a number; restating it adds noise without safety. But a public function’s signature is a contract others depend on, so make it explicit and stable.

// noisy — inference already knows these
const name: string = "Ada";
const items: number[] = [1, 2, 3];

// valuable — the contract is explicit
export function createUser(input: CreateUserInput): Promise<User> { ... }

Over-annotating locals makes code harder to read and refactor (every type change means editing two places); under-annotating public functions removes the documentation and safety types exist for. The rule of thumb: if the compiler can obviously infer it and it’s local, leave it; if it’s a boundary others call across, spell it out.

Avoid any; reach for unknown

The most important discipline is treating any as a code smell. Every any is a hole where type checking stops and runtime bugs return — and worse, any is contagious: it spreads through everything it touches, silently disabling checks far from where you wrote it. A single any in a hot path can quietly un-type an entire call chain.

The alternatives, in order of preference:

Turn on lint rules (no-explicit-any, no-unsafe-*) so any is a deliberate, visible choice rather than an accident. The any-vs-unknown reflex is the single clearest marker of TypeScript maturity: mature code narrows unknown; immature code sprinkles any and wonders why the types didn’t catch anything.

Make impossible states impossible

The highest-leverage idea in typed design: use the type system so illegal states can’t be represented. If your types allow a combination that should never occur, someone will eventually create it and cause a bug. Model the data so the bad combination doesn’t type-check.

The classic example is a loading state modeled as loose booleans:

// ✗ allows nonsense: loading && error && data all set, or none
interface State { loading: boolean; data?: Data; error?: string; }

This permits { loading: true, data: someData, error: "oops" } — an incoherent state your UI must defensively guard against everywhere. Replace it with a discriminated union (post 3) that permits only the valid shapes:

// ✓ only the four real states are representable
type State =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: Data }
  | { status: "error"; error: string };

Now data exists only in the success state, error only in the error state, and impossible combinations simply won’t compile. You’ve moved a class of bug from “handle defensively at runtime” to “cannot occur.” This is type-driven design, and it’s where TypeScript delivers its deepest value — not in annotating what exists, but in making the wrong thing unrepresentable.

Prefer precision, within reason

A few smaller judgments compound:

Each is a small nudge toward types that carry real information rather than rubber-stamping string and object everywhere.

Know when types cost more than they’re worth

Maturity also means restraint. TypeScript’s type system is Turing-complete-adjacent — you can build breathtakingly clever conditional-and-mapped-type contraptions. Usually you shouldn’t. If a type needs a paragraph of explanation, or an error message it produces is three screens long, the cleverness has become a liability: the next engineer (or you, in six months) can’t maintain it.

The judgment: reach for advanced types (post 5) when they eliminate real duplication or real bugs, and stop when they’re complexity for its own sake. A slightly-less-precise type that everyone understands often beats a perfectly-precise one nobody can modify. Types serve the code and the team; when a type stops paying its way in caught bugs and starts costing in comprehension, simplify it. This is the same restraint good engineers apply to abstraction anywhere.

Migrating and living with it

For existing JavaScript, adopt TypeScript incrementally: allow JS and TS side by side, convert file by file, start with loose settings and ratchet toward strict (post 7) as you go. Use unknown and honest types at the boundaries first, tighten internals over time, and let each converted module catch its own bugs. The goal is steady progress to strict, not a big-bang rewrite.

Pulling the series together: TypeScript is a compile-time structural type checker that erases to JavaScript, and using it well is a discipline — infer locally and annotate boundaries, refuse any in favor of unknown and real types, model your domain so illegal states can’t exist, prefer precision where it carries information, and simplify types that cost more than they catch. Do this on a large codebase and the type system stops being ceremony and becomes what it promised at the start of the series: a machine that catches whole categories of bugs before your users ever see them.

Key takeaways

Further reading

Sources & References

Making impossible states impossible