TypeScript on a JavaScript codebase, one file at a time

The bug was that the API stopped sending a field. Not an error, not a 500 — the field was optional in a response that had never declared what it contained, a component read it, and eleven thousand users saw a blank panel for two days. The shape of an API response is a promise nobody had written down, and TypeScript is a way of writing it down that the build can check.

The symptom

TypeError: Cannot read property 'toFixed' of undefined
    at RevenueWidget (RevenueWidget.js:24)

# what the endpoint used to return
{ "total": 41204, "currency": "GBP", "forecast": 44100 }

# what it returns now, after a release nobody on this team made
{ "total": 41204, "currency": "GBP" }

$ git log --oneline -1 --  src/RevenueWidget.js
  a3f9c11  (14 months ago) add the forecast panel

The frontend had not changed in fourteen months. The contract changed, nothing checked it, and the failure surfaced as a runtime error in a component whose author had left. Every part of that is normal and none of it is anybody’s mistake exactly.

Why it happens

JavaScript has no way to state what a value is, so the shape of an API response exists only in the code that reads it — and that code is spread across every component that touches the data. There is no single place where the contract is written, which means there is no place to check it against.

The usual objection to TypeScript is that it is a rewrite, and that objection is what the incremental options exist to answer. It is worth knowing that the answer exists before deciding, because “we would have to convert everything” is both the standard reason for not adopting it and untrue.

The fix

allowJs, checkJs, and a per-file opt-in

{
  "compilerOptions": {
    "target": "es2018", "module": "esnext", "jsx": "react",

    "allowJs": true,     // .js files compile
    "checkJs": false,    // and are NOT checked, yet
    "strict": false,     // one flag at a time
    "noEmit": true       // webpack emits; tsc only checks
  },
  "include": ["src"]
}

With allowJs and checkJs: false the compiler processes the whole codebase and complains about nothing, which is the state that makes the first day possible. A single // @ts-check comment at the top of one file opts that file in, and a .ts extension opts it in fully.

noEmit matters more than it looks: the build stays with webpack and babel, and tsc becomes a checker that runs in CI. That means adopting TypeScript does not change how anything is bundled, which removes the largest source of risk from the migration.

Typing the API boundary, which is where the value is

// src/api/types.ts — the contract, written down once
export interface Revenue {
  total: number;
  currency: string;
  forecast?: number;        // optional, and now the compiler knows
}

export type Result<T> =
  | { status: 'ok'; data: T }
  | { status: 'error'; code: string; message: string };

// and the fetch, which is the only place a lie can be told
export async function getRevenue(id: string): Promise<Result<Revenue>> {
  const res = await fetch(`/api/revenue/${id}`);

  if (!res.ok) {
    return { status: 'error', code: String(res.status), message: res.statusText };
  }

  return { status: 'ok', data: (await res.json()) as Revenue };
}

Converting five files at the API boundary caught the original bug immediately: forecast declared optional means every read of it is a compile error until it is guarded. Nothing else in the codebase had to change for that to work, which is the property that makes the incremental approach worth the effort.

The as Revenue cast is the unchecked claim and it is the one place in the whole arrangement where the compiler is being lied to. That is acceptable if it is confined to one function per endpoint — and it is exactly where a schema validation library earns its place, because the cast can then be a parse that fails loudly.

The discriminated union for the result is what makes the calling code exhaustive: after checking status === 'ok' the compiler knows data exists, and it refuses code. That narrowing is the single highest-value TypeScript pattern for anything consuming an API.

strictNullChecks, which is the flag that pays and the one that hurts

// strictNullChecks: false — compiles, throws at runtime
function greet(user: User) {
  return user.name.toUpperCase();
}

// true — the type has to say so, and every caller is checked
function greet(user: User | null) {
  if (user === null) return '';

  return user.name.toUpperCase();
}

// and 3.7, which makes the guarded version bearable
const city = order?.address?.city ?? 'unknown';

Without it, every type silently includes null and undefined, so the compiler agrees that a possibly-missing value is a string and the runtime disagrees. It is the flag that converts TypeScript from documentation into a check, and turning it on produced 412 errors on eleven converted files — most of which were real.

Optional chaining and nullish coalescing landing in 3.7 in November is what makes the guarded code readable rather than a wall of conditionals. The distinction between ?? and || matters here and is worth stating: || falls through for 0, '' and false, so a default applied with it overrides a legitimate zero.

A ratchet, so it only goes one way

set -euo pipefail

# every checked file must pass; unchecked ones are ignored
npx tsc --noEmit

# and the counts, reported in every build
checked=$(grep -rl '@ts-check' src | wc -l)
ts_files=$(find src -name '*.ts*' | wc -l)
echo "typed: ${ts_files}, checked-js: ${checked}"

The ratchet is what stops the migration stalling at eleven files. Reporting the count in every build makes the trend visible without anybody having to look for it, and a rule that new files must be .ts means the proportion improves without a project.

The order that worked was API types first, then the components that consume them, then utilities, then everything else — which front-loads the value. Converting utilities first is tempting because they are easy and it produces very little benefit.

Verifying it worked

# the original bug, reintroduced deliberately
$ git revert --no-commit a3f9c11
$ npx tsc --noEmit
src/RevenueWidget.tsx:24:31 - error TS18048:
  'revenue.forecast' is possibly 'undefined'.

$ npx tsc --noEmit && npx jest
 Tests: 214 passed

# six weeks later
$ find src -name '*.ts*' | wc -l
88
$ find src -name '*.js*' | wc -l
141          # was 229 and 0

Reintroducing the original bug and watching the compiler refuse it is the assertion that says the migration achieved its purpose, and it is worth doing rather than assuming — a type that is declared and never read is a type that catches nothing.

Eighty-eight of two hundred and twenty-nine files after six weeks, with no dedicated project time, is what the incremental approach buys. The important number is not the ratio but that the API boundary is entirely converted, because that is where the failures were.

What this costs

A build step and a type definition that can lie. Every as cast, every any and every hand-written declaration for an untyped dependency is an unchecked claim, and a wrong one moves the failure from the compiler to production — which is worse than having no types, because it comes with false confidence. Grepping for as and : any periodically is a genuinely useful review, and the count belongs in the same build output as the file counts.

The second cost is that a mixed codebase is harder to reason about than either pure state. A checked file importing an unchecked one gets any for everything, so the guarantees stop at the boundary and the boundary is invisible. That is the price of not doing a rewrite, and it is worth paying — but it means the intermediate state has less value than the file count suggests, and saying so keeps expectations honest.