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:
.d.tsshims for stable legacy modules with documented surface- Wrapper functions in
.tsthat validate outputs with Zod before crossing inward - Rename to
.tson 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 optionstsconfig.strict.json— extends base withstrict: true- Per-package configs reference strict for
src/features/**and loose forsrc/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 propagationstrictNullChecks— highest bug catch per effort; tackle feature slicesnoUncheckedIndexedAccess— 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
anyin strict zones — useunknownor generics - No new JS in
apps/weborapps/api— TypeScript only for new files - Suppressions require tickets —
@ts-expect-errorwith 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-errorwithout reintroducingany? - Are shared contracts in a package both apps import?
- Did CI run
tscon 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.