I was in the middle of a client hand-off when the live site started throwing Cannot read property 'length' of undefined on a checkout page. The repo was pure TypeScript, all the IDE warnings were gone, and the build passed without a hitch. Yet the production environment was blowing up. That afternoon I learned three things that still shape how I ship TypeScript today.

The illusion of "type safety" in the wild

TypeScript’s static analysis is fantastic, but it only protects you while the code is being compiled. Once the JavaScript lands in the browser or on a Node server, everything becomes just JavaScript again. Anything that can happen at runtime, missing data, third-party API changes, environment differences, is still possible.

Common gaps that survive compilation

  • Any creeping in through libraries that ship no types or expose any in their definitions.
  • Unchecked external data, API responses, query strings, cookies, that bypass the compiler because they are typed as unknown or any and then cast.
  • Non-deterministic builds, different Node versions, missing polyfills, or mismatched environment variables can change behaviour.

I’ve seen a project where a third-party payment SDK returned null instead of an object after a recent update. The SDK’s type file still declared the return type as an object, so the compiler never raised a flag. In production the code tried to access payment.id and crashed.

Enable the strictest compiler options you can tolerate

When I first started using TypeScript I left most strict flags off because they felt like noise. After a few production incidents I turned them on one by one. The ones that made the biggest difference were:

  • strictNullChecks
  • noImplicitAny
  • exactOptionalPropertyTypes
  • noUncheckedIndexedAccess

Turning these on forces you to think about every possible undefined or null value and to avoid the silent any that hides bugs.

Quick checklist for compiler config

// tsconfig.json snippet
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "exactOptionalPropertyTypes": true,
    "noUncheckedIndexedAccess": true,
    "skipLibCheck": false
  }
}
  • "strict": true enables a bundle of strict checks.
  • "skipLibCheck": false makes the compiler validate the type definitions of your dependencies.
  • Adjust "target" and "module" only if you need older browsers; otherwise keep them modern to avoid polyfill surprises.

Runtime validation is not optional

Even with strict mode you still need to verify data that comes from outside your code. I now treat any value that crosses a trust boundary as unknown and run it through a validator before casting.

A tiny validation helper

// utils/validate.ts
export function isString(val: unknown): val is string {
  return typeof val === "string";
}

export function requireString(val: unknown, name: string): string {
  if (!isString(val)) {
    throw new TypeError(`${name} must be a string`);
  }
  return val;
}
  • isString is a type guard that narrows unknown to string.
  • requireString throws early if the contract is broken, preventing later undefined errors.

I wrap every API response in a small schema check, often using a library like Zod or Yup, and the crashes I used to see from malformed JSON disappeared.

Source maps and proper error reporting

When a production crash hits, the stack trace you get from the browser or Node is usually minified. Without source maps you’re staring at bundle.js:12345 and guessing which module it belongs to. I made a mistake early on by disabling source maps in production to save a few kilobytes. The result was a ticket that took hours to reproduce.

How to keep source maps useful and safe

  • Generate external source maps (.map files) and serve them only to authorized users or via a debug endpoint.
  • Use a service like Sentry, LogRocket, or Datadog that can ingest the source maps and de-obfuscate the stack automatically.
  • Strip out any source code from the maps; they should contain only the compiled output and line numbers.

Testing beyond the happy path

Unit tests give you confidence that a function does what you think, but they rarely hit the edge cases that cause production crashes. I now supplement my test suite with two extra layers:

  1. Integration tests that hit real APIs (or a realistic mock) and assert on response shapes.
  2. Fuzz tests that feed random but type-correct data into critical functions.

A simple fuzz harness can be written with fast-check:

import { fc } from "fast-check";
import { processOrder } from "../src/order";

fc.assert(
  fc.property(fc.anything(), (input) => {
    // The function should never throw for any input shape
    try {
      processOrder(input as any);
    } catch (e) {
      // If it throws, we log the offending input for later analysis
      console.error('Fuzz failure', input, e);
      return false;
    }
    return true;
  })
);
  • fc.anything() generates random values of any type.
  • The wrapper catches unexpected exceptions and logs the problematic payload.

After adding this fuzz test, I caught a case where a numeric ID arrived as a string and the code attempted to call .toFixed() on it, a bug that had never shown up in manual tests.

Deploy with a safety net

Even with all the checks, something can slip through. A good deployment strategy gives you a way to roll back quickly.

  • Blue-green deployments: keep the old version running behind a load balancer while you shift traffic to the new one. If errors spike, flip back.
  • Feature flags: hide risky code behind a flag that you can toggle without redeploying.
  • Health checks: add an endpoint that runs a few sanity checks (e.g., can the DB be queried, does a known API return expected shape). Your CI/CD pipeline should abort the release if the health check fails.

FAQ

Why does TypeScript still allow any after enabling strict mode?

any can still appear if a library’s type definitions explicitly use it or if you cast with as any. The compiler trusts the type declarations, so you need to audit third-party types or replace them with stricter alternatives.

How can I catch missing environment variables before they cause a crash?

Load all required env vars at startup, validate them with a schema (e.g., using zod), and abort the process if validation fails. This turns a runtime undefined error into a clear start-up failure.

Is it worth keeping source maps in production?

Yes, as long as you don’t expose the raw source code. Serve the maps only to your error-tracking service or restrict them to authenticated users. The debugging benefit far outweighs the tiny bandwidth cost.

Wrapping up

If you think TypeScript alone guarantees a crash-free production, you’re wrong. Treat the type system as a first line of defense, then add runtime validation, strict compiler flags, proper source maps, and robust testing. When you combine those practices with a safe deployment workflow, the days of mysterious production crashes become a rarity.