TypeScript Migration and Adoption

JS→TS Migration & Incremental Adoption

A lang-js-ts reference for taking an existing JavaScript codebase to TypeScript incrementally, without a stop-the-world rewrite and without a red CI. The goal: keep the app shipping while types arrive file-by-file, get type checking before you change a single extension, sequence the strictness flags so each one is a bounded chunk of work, and treat suppressions as debt you can count and burn down. This is the journey — ordering, decisions, and debt management. It is not the flag catalog: every compilerOptions flag’s exact semantics, defaults, and the module quartet live in typescript-compiler-config; this doc names a flag and links there rather than re-documenting it.

Overview

A successful migration has four phases that overlap, not a single switch:

  1. Enable TS over the JSallowJs: true so .js compiles alongside .ts; optionally checkJs/// @ts-check to start checking the JS in place. No renames yet. The build still produces the same output.
  2. Get types onto the JS via JSDoc — annotate hot/leaf modules with JSDoc so you catch real bugs and design the types before converting. JSDoc has limits (below); where it can’t express a type, that file is a rename candidate.
  3. Rename incrementally.js.ts (.jsx.tsx) a module or directory at a time, fixing the errors that surface, keeping CI green after each batch.
  4. Ramp strictness — start loose, turn on one flag at a time (lead with strictNullChecks), drive noImplicitAny to true as the milestone, and burn down the any/@ts-expect-error debt accumulated along the way.

The throughline: tsc --noEmit is the type gate, your existing bundler/transpiler keeps doing the build. You never block shipping on the type-checker until you choose to.

Version anchor (TS 5.x vs 6.0 — this changes your starting point). Through TS 5.x the compiler default is strict: false, so a fresh tsconfig.json is permissive and you opt into strictness. TS 6.0 (released 2026) flips strict to default true — the release notes are explicit: “If you were relying on the previous default of false, you’ll need to explicitly set "strict": false in your tsconfig.json.” For a JS codebase adopting TS on 6.0, that means: write "strict": false explicitly in step 1, or your very first tsc run buries you under strictNullChecks + noImplicitAny errors across the whole tree at once — the exact all-at-once trap this skill exists to avoid. 6.0 also defaults module: esnext, target to a floating current-year spec (currently es2025), types: [], and makes esModuleInterop/allowSyntheticDefaultImports undisablable. Pin strict, target, module, and lib explicitly so a 5.x and a 6.0 toolchain give the same migration baseline. (Flag defaults table → typescript-compiler-config.)

Core Concepts

Phase 1 — Enable TS over a mixed codebase (allowJs + checkJs)

allowJs: true lets .js/.jsx files into the program so TypeScript and JavaScript coexist and import each other. On its own it gives you nothing but co-compilation. Add checking in one of two granularities:

The official “Migrating from JavaScript” handbook page predates this workflow — it leads with target: es5 + noEmitOnError and never mentions checkJs/@ts-check. The current practice is checkJs-first: check the JS in place, fix and annotate, then rename. Ground the checkJs/@ts-check mechanics in the handbook’s “Type Checking JavaScript Files” page instead. (Flagging this because the most-linked guide is stale on exactly this point.)

A minimal phase-1 tsconfig.json that compiles a mixed tree and checks nothing yet (you turn checkJs on, or sprinkle // @ts-check, when ready):

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "strict": false,
    "noImplicitAny": false,
    "target": "es2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "lib": ["es2022"],
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true,
    "outDir": "dist"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

noEmit: true makes tsc a pure checker — your bundler/swc/Babel/tsx still builds. (If you want tsc itself to emit during migration, drop noEmit and set outDir; pair with noEmitOnError: false early so type errors don’t block the JS output you already shipped.)

Phase 2 — JSDoc-as-types (and its limits)

Before renaming, you can express real types in .js with JSDoc and get full checking under // @ts-check/checkJs. This is high-leverage: you find bugs and design the type surface with zero syntax churn, and a clean JSDoc’d file renames to .ts almost trivially.

// @ts-check

/**
 * @param {string} id
 * @param {{ retries?: number; signal?: AbortSignal }} [opts]
 * @returns {Promise<User>}
 */
async function fetchUser(id, opts) { /* ... */ }

/**
 * @typedef {Object} User
 * @property {string} id
 * @property {string} name
 * @property {number} [age]   // optional
 */

/** @type {User[]} */
const users = [];

/** @template T @param {T} x @returns {T} */
const identity = (x) => x;

// Import a type from another module (TS 5.5+ `@import`, or inline `import()`):
/** @import { Config } from "./config.js" */
/** @type {Config} */
let cfg;
/** @param {import("./db.js").Client} client */
function withClient(client) {}

// Cast (parenthesize the expression):
const el = /** @type {HTMLInputElement} */ (document.getElementById("email"));

Supported tags worth knowing: @type, @param, @returns, @typedef + @property, @callback, @template (with constraints @template {string} K and defaults @template [T=object]), @satisfies (TS 4.9+), @enum, @this, @extends/@implements/@override, and @import/import() for cross-file types.

Limits — where JSDoc can’t reach (these files are your rename candidates):

Phase 3 — Renaming: leaf-first vs entry-first

Renaming a file from .js to .ts flips it from “checked only if // @ts-check” to “always checked, with full TS rules.” Two orderings:

Most migrations are leaf-first with a few entry-first interface files drawn early to anchor the domain model. Convert in small batches that each keep tsc --noEmit passing (or only adding tracked suppressions); a batch should be a reviewable PR, not a 400-file flag day.

Phase 4 — The strictness ramp

Turn flags on one at a time, each as its own PR-sized chunk, instead of strict: true in one commit (which dumps every category of error simultaneously).

  1. strictNullChecks first. The single highest-value flag — null/undefined become distinct types. Biggest bug-catch, and it unlocks JSDoc nullability and strictPropertyInitialization. Do this before the rest of the strict family.
  2. The rest of the strict family, roughly cheapest-first: noImplicitThis, alwaysStrict, strictBindCallApply, strictFunctionTypes, strictPropertyInitialization, useUnknownInCatchVariables. (Membership/semantics → typescript-compiler-config.)
  3. noImplicitAny: true is the milestone, not the starting gun. Keep it false during phases 1–3 — flip it early and your still-untyped .js/freshly-renamed .ts files erupt in implicit-any errors everywhere at once. Reaching noImplicitAny: true means “there is no silent any left in the codebase” — it’s the line that certifies the migration’s core is done. (Alternative framing: the handbook suggests turning noImplicitAny on early if the team will annotate aggressively from day one. On a checkJs/JSDoc-first migration, milestone-not-gate is the calmer path.)
  4. anyunknown cleanup. The provisional anys you and ts-migrate scattered are unsafe (they disable checking and propagate). Replace deliberate escape hatches with unknown, which forces a narrowing check at the use site before the value is touched. Track the count of remaining any (grep, or typescript-eslint’s no-explicit-any) and drive it down.
  5. High-value standalone checks last (optional): noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitOverride — only when the team will pay the friction.

Per-directory tsconfig overrides let different parts of the tree sit at different strictness during the ramp. A migrated subtree can be strict while the rest stays loose:

// src/payments/tsconfig.json  — this subtree is fully migrated, hold it to a higher bar
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "noUncheckedIndexedAccess": true
  },
  "include": ["./**/*"]
}

This ratchets: once a directory is strict, new code there can’t regress. (Project references / tsc -b wiring for multi-config builds → typescript-project-references-monorepo.)

Suppressions as tracked debt: @ts-expect-error vs @ts-ignore

Both silence the error on the next line. The difference is what happens when the underlying error goes away:

Burn-down practice: standardize on @ts-expect-error with a reason comment (// @ts-expect-error TODO(#1234): widen Config type); ban @ts-ignore via typescript-eslint ban-ts-comment ({ "ts-ignore": true, "ts-expect-error": "allow-with-description" }). Track the count over time (grep -rc "@ts-expect-error" src | ...) as a burn-down metric — a migration is “done” when both the @ts-expect-error count and the any count trend to zero. (Typed-lint rules to enforce this → typescript-eslint-typed-linting.)

Tools / Frameworks

Methodology

  1. Add tsconfig.json with allowJs: true, noEmit: true, strict: false, noImplicitAny: false — and on TS 6.0 write "strict": false explicitly. Pin target/module/lib. Wire tsc --noEmit into CI as non-blocking first (report, don’t fail).
  2. Turn on checking gradually// @ts-check on a handful of leaf files (or checkJs: true if the tree is small). Fix what surfaces.
  3. Annotate with JSDoc the modules you check, designing the type surface in place. When a file needs types JSDoc can’t express, mark it for rename.
  4. Rename leaf-first in PR-sized batches, drawing a few entry-level interface files early. Keep each batch green (or adding only tracked @ts-expect-errors).
  5. Make tsc --noEmit blocking in CI once the tree compiles.
  6. Ramp strictness one flag at a time: strictNullChecks → rest of strict family → anyunknownnoImplicitAny: true (milestone) → optional standalone checks. Use per-directory overrides to ratchet finished subtrees.
  7. Burn down debt: track @ts-expect-error and any counts to zero; replace declare module stubs with real @types.
  8. Run attw before publishing if it’s a library.

Practical Patterns

CI: type gate that doesn’t block the build (early), then does (later).

# .github/workflows/ci.yml (excerpt)
jobs:
  build:
    steps:
      - run: npm ci
      - run: npm run build          # bundler / swc / tsx — the real artifact
  typecheck:
    continue-on-error: true         # PHASE 1-3: report, don't block. Flip to false once green.
    steps:
      - run: npm ci
      - run: npx tsc --noEmit       # the type gate

Tracking the debt (drop in a script or CI step):

echo "any:          $(grep -rIn --include=*.ts -e ': any' -e '<any>' src | wc -l)"
echo "ts-expect:    $(grep -rIn --include=*.ts '@ts-expect-error' src | wc -l)"
echo "ts-ignore:    $(grep -rIn --include=*.ts '@ts-ignore' src | wc -l)   # target: 0"
echo "remaining js: $(find src -name '*.js' | wc -l)"

One file mid-migration, JSDoc → soon-to-be-.ts:

// @ts-check
/** @typedef {import("./types.js").Invoice} Invoice */

/**
 * @param {Invoice} inv
 * @returns {number}
 */
export function total(inv) {
  // @ts-expect-error TODO(#88): lineItems untyped until billing/ is migrated
  return inv.lineItems.reduce((s, li) => s + li.amount, 0);
}

Anti-Patterns

Troubleshooting

References