SEO

TypeScript strict mode: lessons from production

Two years of TypeScript strict mode on a 200,000-line codebase. What we learned, what we wish we had known, and the rules we follow.

What two years taught us

Strict mode finds real bugs. After two years and 200,000 lines, here is what we learned. These same strict TypeScript principles apply to our Next.js 16 edge runtime lessons — the same type safety that catches bugs in libraries prevents runtime errors in production builds.

Which flags earn their place

Strict mode is a set of switches. Not all of them pull their weight.

Turn these on:

  • strictNullChecks. Finds the most bugs. Without it, null and undefined drift right past you.
  • noImplicitAny. Makes you type each input and each output.
  • strictFunctionTypes. Catches bad swaps of inputs.
  • noImplicitReturns. Makes each path hand back a value.
  • noFallthroughCasesInSwitch. Catches a missing break line.
  • noUncheckedIndexedAccess. Marks list hits as maybe empty.

Turn these off:

  • noImplicitThis. Not worth it. Skip it.
  • strictBindCallApply. Not worth it. Skip it.
  • alwaysStrict. Already on. No need to touch it.

The three flags that catch 80 percent of bugs: strictNullChecks, noImplicitAny, strictFunctionTypes. Start there.

Discriminated unions are the best tool

We build each piece of UI state as a discriminated union.

It looks like this:

type State =
  | { kind: 'loading' }
  | { kind: 'ok'; data: T }
  | { kind: 'error'; error: string };

TypeScript picks the state for you in each branch. The data field shows only in the ok case. Miss a case and the code will not build.

This shape holds across 200,000 lines of code. See how we put this to work in our tech stack choices for SaaS MVPs. The same rules hold there too.

Skip the any type

any turns off the checks. It spreads. Take any in, get any out, and the bad type keeps on.

Use unknown instead. You must cut it down before use.

function parseUserInput(input: unknown): User {
  if (typeof input !== 'object' || input === null) {
    throw new Error('Bad input');
  }
  const obj = input as Record<string, unknown>;
  if (typeof obj.name !== 'string') throw new Error('No name');
  return { name: obj.name };
}

Each outside input (API reads, parsed JSON, form data) gets checked at the door. Inside, the type is known. The same rule holds for API routes in our Next.js 16 lessons. Server actions do the same job.

Type-only imports

Use import type to keep a type out of the shipped code. That trims the bundle and stops circular imports.

The rule: if you only use a thing as a type, use import type. The compiler checks it.

Branded types stop ID mixups

UserId and OrderId are both plain strings. They must never swap. Branded types stop that:

type UserId = string & { readonly brand: unique symbol };

OrderId is the same shape with its own brand. TypeScript tells them apart when it builds. That finds real bugs in big repos.

Type tests for libraries

If you ship a library, add type tests with expectTypeOf or tsd. They catch type drift in CI. Change a type by accident and the test fails. Type tests are part of the same quality bar as our accessibility audit playbook — both catch bugs before they ship, just in different dimensions.

What scales

These held across 200,000 lines:

  • Union types for state.
  • Branded types for IDs.
  • unknown for outside data.
  • Type-only imports for shared types.
  • Result types for risky calls: { ok: true; value: T } or { ok: false; error: string }.

What breaks at scale

These caused pain:

  • Class trees. Use unions instead. The same anti-pattern of over-engineering a solution shows up in how teams choose frameworks — see our headless CMS decision guide for how teams pick the wrong tool and then build around it.
  • Type assertions (as Type). Use them when you must.
  • Mixins. Use parts.
  • Function overloads. Strong but hard to keep up.

The same calls hold in our SaaS MVP guide. Strict mode is one rule we never drop.

The one rule that matters most

If code takes outside data, its first input is unknown. Cut it down inside. Hand back a typed value.

That one rule catches most bugs in our repo. Each outside input gets checked at the door. Inside, all is typed.

Our services

Services related to this post.

  • custom app and saas development

    Custom web app development

    Internal tools, dashboards and customer-facing apps. Custom web apps with React or Vue, integrated with your existing systems, documented handover with a clear runbook. Fixed scope, two-week increments, working software every time.

Questions

Frequently asked questions

Common questions about typescript strict mode: lessons from production, answered plainly.

Should I enable TypeScript strict mode for new projects?
Yes, always. The cost is one to three days of cleanup at the start. The benefit is catching real bugs before they ship.
Which strict flags matter most?
strictNullChecks, noImplicitAny, strictFunctionTypes. These three catch 80 percent of the bugs. The rest of strict mode is mostly catch-up.
What is the difference between any and unknown?
any disables type checking. unknown requires you to narrow the type before using it. Use unknown for any external input (API responses, user input, parsed JSON).
What is a discriminated union?
It is a union type with a common property that has a literal value. The kind property lets TypeScript narrow automatically.
What is a branded type?
It is a type that prevents two structurally identical types from being mixed. UserId and OrderId are both strings at runtime but TypeScript treats them as different.
Should I use TypeScript ESLint?
Yes for new projects. The rules catch a class of bugs that strict mode misses (unused variables, missing return types on exports, unsafe any).

Was this article helpful?

One tap. No email, no signup.

WebGrow24 services

Need help putting this into action?

Fixed scope, 48-hour turnaround. Senior team. Outcome-led.