·9 min read·

A year of moving everything to TypeScript.

We made the call at the start of 2019: every new project, TypeScript. Every old project, gradual migration. Twelve months on, here's the verdict — what got better, what got harder, and the patterns we now recommend by default.

Twelve months ago we drew a line: every new project starts in TypeScript. Every existing project gets migrated as we touch it. No 'we'll add types later' — that promise never gets kept. After a full year of the rule, here's an honest field report — what worked, what didn't, what we'd do differently, and the patterns we now reach for by default.

What got better

  • 01Refactoring became safe. Renaming a prop across thirty files is now a confident operation, not an archaeology dig.
  • 02Onboarding sped up. New engineers read the types and can navigate code they've never seen.
  • 03The cost of saying 'no' to a vague API became real — fuzzy backend contracts get pushback at design time, not after release.
  • 04Bugs caught at compile time stopped becoming bugs caught in production at 9pm.
  • 05Code review got sharper. The reviewer can trust the types and focus on logic.
  • 06IDE support is excellent — VS Code, IntelliJ, Vim with coc-tsserver, everyone gets autocomplete and inline errors.
  • 07Boundaries between modules became clearer. Type signatures force you to think about what crosses a boundary.

What got harder

  • 01Third-party libraries with bad or missing types. We've written more than a few .d.ts files this year.
  • 02Generic type wrangling, especially around Vue's reactive primitives, eats time the first time you do it.
  • 03The build is slower. Cache it, parallelise it, but it is slower.
  • 04Junior engineers face a steeper learning curve in week one. Worth the cost, but plan for it.
  • 05Some library authors actively dislike TypeScript — type packages lag behind library releases.
  • 06The temptation to over-engineer types is real — a 200-character generic that nobody can read is worse than a well-placed comment.

Patterns that pay off

  • 01Treat your API as a typed contract. Generate types from the schema, don't hand-write them.
  • 02Use discriminated unions for any 'one of these shapes' data. They turn runtime checks into compile-time ones.
  • 03Resist 'any'. Every 'any' is a hole the team will fall through later. Use 'unknown' and narrow.
  • 04Adopt strict mode from day one of a new project. Adding it later costs more than starting with it.
  • 05Type the boundaries first. Internal helpers can stay loosely typed if velocity matters; the public surface of every module must be typed.

Discriminated unions in practice

The single most useful pattern we've added to our toolkit. Replaces every 'if (something.type === "this") { ... }' check with type-narrowed control flow that the compiler proves correct.

types/booking-status.tstypescript
// Define each variant with a 'kind' discriminator
type BookingPending = {
  kind: 'pending'
  bookingId: string
  expiresAt: Date
}

type BookingConfirmed = {
  kind: 'confirmed'
  bookingId: string
  confirmedAt: Date
  receiptUrl: string
}

type BookingCancelled = {
  kind: 'cancelled'
  bookingId: string
  cancelledAt: Date
  reason: string
}

export type BookingStatus =
  | BookingPending
  | BookingConfirmed
  | BookingCancelled

// The compiler narrows the type inside each branch
function describe(status: BookingStatus): string {
  switch (status.kind) {
    case 'pending':
      // TypeScript knows expiresAt exists here
      return `Pending until ${status.expiresAt.toISOString()}`
    case 'confirmed':
      // TypeScript knows receiptUrl exists here
      return `Confirmed, receipt: ${status.receiptUrl}`
    case 'cancelled':
      // TypeScript knows reason exists here
      return `Cancelled: ${status.reason}`
  }
}

Strict tsconfig is the only sensible default

The single biggest win from this year was committing to strict mode and never opting out. The default tsconfig everywhere we ship now looks like this.

tsconfig.jsonjson
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "node",
    "strict": true,
    "noImplicitAny": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

A year in, we'd never go back. The cost of typing every prop is genuinely tiny compared to the cost of debugging an untyped one at scale.

The remaining migration ahead is the long tail of small legacy projects we don't touch often. We don't expect to convert them all. The rule going forward stays the same: new work in TypeScript, old work in TypeScript when we open it. Two more years and JavaScript will be the rare exception in our codebase, not the rule.

Talk to Remiam about a system like this.