typescript6 min read

Gradual Typing Strategies for Growing Codebases

Move a large JavaScript or loose TypeScript repo toward safety incrementally—boundaries, ratchets, and team habits that avoid the big-bang migration trap.

Not every codebase starts strict. Acquisitions, prototypes, and “just ship it” years leave JavaScript cores with TypeScript veneers. A big-bang strict: true PR often dies in review. Gradual typing adds safety where risk is highest, expands coverage as files change, and uses the compiler as a ratchet—not a gate on every legacy line at once.

This guide complements TypeScript for full-stack teams that ship, designing APIs with types that survive, and Zod at the edges. Official references include the TypeScript handbook and tsconfig strict options.

Assess heat, not file count

Map modules by change frequency and blast radius:

  • High heat: auth, billing, permissions, public API handlers
  • High blast: shared utils imported everywhere
  • Low priority: one-off scripts, archived admin tools

Type the hot perimeter first. Leave cold legacy JS alone until touched—or forever if risk is truly low and isolation is real.

The boundary-first rule

Untyped JS calling typed TS without checks is a false sense of security. Strategies:

  1. .d.ts shims for stable legacy modules with documented surface
  2. Wrapper functions in .ts that validate outputs with Zod before crossing inward
  3. Rename to .ts on touch — any substantive edit converts the file and fixes errors locally

Document allowed import directions: new code may not import from legacy/ without going through legacy/adapters.ts.

Ratchet strictness with tsconfig paths

Use solution-style configs:

  • tsconfig.base.json — shared compiler options
  • tsconfig.strict.json — extends base with strict: true
  • Per-package configs reference strict for src/features/** and loose for src/legacy/**

When a folder flips to strict, delete overrides in the same PR. Track remaining loose folders in a visible dashboard issue.

Enable flags incrementally where full strict is blocked:

  • noImplicitAny — stops silent any propagation
  • strictNullChecks — highest bug catch per effort; tackle feature slices
  • noUncheckedIndexedAccess — excellent for arrays and records; noisy at first

The compiler handbook on narrowing helps reviewers teach patterns instead of sprinkling !.

JavaScript with JSDoc as a bridge

TypeScript checks .js files with @ts-check and JSDoc types. Useful when runtime behavior cannot change yet:

/** @param {string} id */
export function loadUser(id) { ... }

Migrate to .ts when edits are already scheduled. JSDoc is a stepping stone, not the destination for active product code.

Codemods and mechanical wins

Rename require to import in batches. Add satisfies for config objects. Use ESLint @typescript-eslint/no-explicit-any as warning in legacy, error in strict trees.

Avoid codemods that add as any to silence errors—that debt compounds. Prefer unknown + guards per avoiding any: practical patterns.

Team agreements

  • No new any in strict zones — use unknown or generics
  • No new JS in apps/web or apps/api — TypeScript only for new files
  • Suppressions require tickets@ts-expect-error with issue link and owner
  • Weekly burn-down — 30 minutes fixing top-of-stack strict errors in one module

PMs should budget migration time in epics that touch legacy domains—“add tax line item” includes typing the invoice module if it is still JS.

Testing during migration

Types reduce tests but do not replace characterization tests on legacy. Before converting a module, lock behavior with integration tests, then convert and let tsc guide refactors.

For APIs, add Zod parsing at handlers even if inner services remain JS—edges become typed first.

Monorepo and dependency typing

skipLibCheck: true is common for speed; ensure your own packages ship .d.ts. Untyped dependencies force any at import sites—wrap in typed facades:

import untyped from "legacy-lib";
export const legacy: { run: (id: string) => Promise<Result> } = untyped;

Observability and rollback

Ship gradual typing behind normal deploys. Watch error rates on typed routes after conversion. If production issues appear, fix forward—reverting strict flags globally punishes progress.

Culture: progress over purity

Celebrate converted modules and deleted suppressions. Shame blocks participation. Senior engineers pair on first strict-null fixes in gnarly React components—often undefined versus missing props confusion tied to App Router mental model data loading.

When to stop migrating

Some folders are stable, isolated, and scheduled for deletion. Document “intentionally loose until Q4 decommission” instead of endless guilt. Gradual typing serves risk reduction, not aesthetic 100% TS coverage.

Checklist for each sprint

  • Did we type at least one edge or hot module?
  • Did we remove @ts-expect-error without reintroducing any?
  • Are shared contracts in a package both apps import?
  • Did CI run tsc on affected packages?

IDE and developer experience wins

As strictness spreads, autocomplete and rename-symbol across packages improve. Celebrate that in retros—junior engineers often feel the benefit first when navigation replaces grep. Configure typescript.preferences.importModuleSpecifier consistently so jumps land in .ts sources, not duplicate declarations.

Aligning with release trains

If you ship weekly, attach typing goals to release themes: “Billing slice strict-null this train.” Avoid orphan PRs that convert 200 files without product tie-in; they stall and breed resentment.

Shared types with design and QA

QA test cases referencing field names should match API types—export example fixtures from the contract package for Postman or Playwright imports. Designers referencing data in Figma annotations benefit when field names match typed DTOs in specs.

Interop with shipping fast and performance budgets

Loose client bundles sometimes hide any in data layers that fetch too much. Typing list versus detail DTOs often reveals over-fetching during migration—use that as a performance win story, not only a safety story.

Ownership and code archaeology

Assign module owners for legacy folders in CODEOWNERS. When strict conversion PRs touch auth or payments, owners review. Archaeology sessions—read old JS without changing it—help estimate conversion cost before committing sprint capacity.

Dual compilation during transition

Some repos run both Babel and tsc for emit during migration; others use tsc --noEmit only while bundlers transpile. Pick one documented path so new hires are not confused. The TypeScript project references handbook helps split composite packages when build times grow.

Measuring progress

Track: count of .js files in product paths, count of @ts-expect-error, strict folder percentage. Graph monthly in engineering reviews—visual progress sustains multi-quarter efforts better than verbal “we are almost done.”

Vendor and fork maintenance

Vendored SDKs without types block strict adoption at integration points. Budget time to write thin .d.ts shims or contribute upstream typings. Forks should document divergence so merges from upstream re-trigger type checks on your wrapper layer.

Security-sensitive modules first

Auth token parsing, session deserialization, and permission checks deserve strict types early—even if marketing pages remain JS another quarter. A single confused userId type in session handling is worse than a dozen untyped utility scripts.

Pairing with platform upgrades

Node and framework major upgrades are natural moments to tighten types in touched files. Bundle “upgrade React” with “convert dashboard/ to strict-null” so migration work rides visible platform wins instead of invisible-only refactors.

Document rollback steps for strict flags per folder: if a release regresses, you can loosen one tsconfig path without reverting the entire monorepo migration.

Closing

Growing codebases adopt TypeScript gradually by typing boundaries and hot paths first, ratcheting strict options per folder, and pairing culture with mechanical rules. Use Zod at edges, shared API types for survival, and honest tracking of legacy zones—so safety increases every sprint without a migration that never lands.

Let's talk