Migration Strategy
Migrate gradually, never all at once
Section titled “Migrate gradually, never all at once”A big-bang rewrite of a real JS codebase to TypeScript is how migrations die. The whole point of TypeScript is that it’s a gradual type system: JS and TS can coexist file by file, so you convert incrementally while shipping the whole time.
The phased path
Section titled “The phased path”flowchart LR js["allowJs: TS builds alongside JS"] --> checkjs["checkJs: type-check the JS too"] checkjs --> convert["rename .js to .ts file by file"] convert --> strict["ratchet strict flags one at a time"]
- Turn on
allowJs. Now TypeScript compiles a mixed project — your.jsfiles build unchanged, and you can start adding.tsfiles next to them. - Add
checkJs(and// @ts-check). TypeScript type-checks your JavaScript using inference and JSDoc. You get real errors before converting a single file — a free first pass. - Convert file by file. Rename
.jsto.ts, fix the errors that surface, commit. Start at the leaves (utilities with few dependencies) and work toward the core. - Ratchet strictness.
strictis the default since TypeScript 6.0, so a migrating project starts with"strict": falseexplicitly and removes it in stages — the exact order matters, and gets its own section below.
Each step is shippable. You are never in a broken half-migrated state for long.
Step zero: allowJs + checkJs + JSDoc, before any renaming
Section titled “Step zero: allowJs + checkJs + JSDoc, before any renaming”The first real type-checking a migrating codebase gets doesn’t require converting a single file. checkJs reads standard JSDoc comments as type annotations on plain .js files:
// @ts-check
/** * @param {number} a * @param {number} b * @returns {number} */function add(a, b) { return a + b;}
add(1, "two"); // should be flagged under checkJsRun exactly that file with tsc --allowJs --checkJs --noEmit math.js and it reports a real error on the last line: TS2345: Argument of type 'string' is not assignable to parameter of type 'number'. (verified in scratch — this is the same diagnostic code you’d get from a .ts file, on unmodified .js). This is the free first pass step 2 above refers to: you add JSDoc to the functions your team touches most, turn on checkJs, and get real compile errors — including on code nobody has looked at in years — before committing to renaming anything. It’s also a natural place to start for files you aren’t ready to fully convert: JSDoc is more verbose than native TypeScript syntax, but it’s zero-runtime-cost and works in a plain .js file forever, if that’s where a given file needs to stay.
The strictness ladder, in order
Section titled “The strictness ladder, in order”Once files are converting to .ts, strict is one flag that turns on a dozen others at once — too much for most real codebases in a single step. Migrating projects set "strict": false explicitly and layer the individual flags back on, and the order matters, because each flag’s error count and fix difficulty differ enormously:
noImplicitAnyfirst. This is almost always the biggest wave of errors, but also the most mechanical to fix — most are a missing parameter or return type, not a design problem. Getting this one done first means every flag after it is reasoning about real types, not silentanys hiding the actual shape.strictNullCheckssecond. This is usually the hardest wave — every placenull/undefinedwas implicitly ignored now needs a real decision (a guard, a default, an assertion you can justify). It comes second, afternoImplicitAny, specifically so you’re narrowing real types by this point, notanys that would hide the null errors anyway.strictFunctionTypesandstrictBindCallApplynext. These catch unsound function-type assignments and mistyped.call/.apply/.bindusage — narrower in scope than the first two, and rarely require a design change, just a signature fix.strictPropertyInitializationfor classes. This one is deliberately later because it often surfaces real architectural questions (should this field be optional? assigned in the constructor? given a default?) rather than a one-line fix — better to answer those once the easier waves are already behind you.- The rest (
noImplicitThis,alwaysStrict,useUnknownInCatchVariables, …) last. These are typically low-error-count flags that clean up remaining edges once the big structural waves are done.
When the last flag is on, delete the strict: false line entirely and the project is on the real defaults — not a manually-assembled approximation of them.
@ts-nocheck per file: the ratchet’s escape hatch, used sparingly
Section titled “@ts-nocheck per file: the ratchet’s escape hatch, used sparingly”Some files resist conversion — generated code, a genuinely dynamic module, something scheduled for deletion. // @ts-nocheck as the first line suppresses every error in that one file, letting the rest of the project ratchet forward without waiting on it:
// @ts-nocheckconst total: number = "not a number"; // would be TS2322 -- suppressed for this whole fileexport function broken(x: string): number { return x; // would also error -- also suppressed}@ts-nocheck hides every error in the file, real or not
// @ts-nocheckconst total: number = "not a number"; // would be TS2322 -- suppressed for this whole fileexport function broken(x: string): number { return x; // would also error -- also suppressed} Both real type errors above vanish — tsc --strict reports zero problems for this file, which is exactly the danger: @ts-nocheck is a much bigger hammer than @ts-expect-error, since it silences the entire file, including bugs nobody has looked at yet, not just the one line you intended to skip. Treat it as a tracked, named exception — a comment linking a ticket, a file explicitly listed in a migration tracker — never a quiet default for “this file gave me trouble.”
Measuring progress
Section titled “Measuring progress”A migration needs a number that goes in one direction, or it’s easy to lose track of whether you’re actually converging. Two real, greppable metrics work well together: the count of files still on @ts-nocheck (files with zero real checking), and the ratio of .ts/.tsx files to the total:
total=$(find src -name '*.ts' -o -name '*.tsx' | wc -l | tr -d ' ')nocheck=$(grep -rl '^// @ts-nocheck' src --include='*.ts' --include='*.tsx' | wc -l | tr -d ' ')echo "$((total - nocheck)) / $total files fully type-checked"(Verified as real shell logic against a three-file scratch project — one with a leading // @ts-nocheck, two without — it correctly prints 2 / 3 files fully type-checked.) Track this number (or the any count from the performance-and-pitfalls lesson) in CI, or just in a recurring team check-in — a migration with no visible metric tends to stall quietly once the initial enthusiasm wears off, because there’s nothing showing that the remaining work is shrinking.
any is a tool, then a debt
Section titled “any is a tool, then a debt”During migration, any is a legitimate way to say “not yet typed — keep moving.” The danger is leaving it. Two disciplines keep it honest:
- Make it explicit and greppable. Prefer an explicit
any(or a// TODO: type thisalias) over an implicit one, so you can find and burn them down later. - Ratchet with
noImplicitAny. Once on, the compiler stops silently insertingany— new code must be typed, so the debt can only shrink.
@ts-expect-error beats @ts-ignore
Section titled “@ts-expect-error beats @ts-ignore”When you must suppress an error, use @ts-expect-error, not @ts-ignore:
// @ts-expect-error — legacy shape, tracked in TICKET-123legacyCall(weirdValue);Both silence the error on the next line, but @ts-expect-error also errors if the line stops having an error. So when you later fix the underlying type, the now-unnecessary suppression fails the build and tells you to remove it. @ts-ignore just rots silently. @ts-expect-error is the right-sized tool for one line; @ts-nocheck (above) is the much blunter tool for an entire file, and the two should not be reached for interchangeably.
import type and barrel-file cycles
Section titled “import type and barrel-file cycles”Under verbatimModuleSyntax (the modern replacement for isolatedModules’s import-related checks), an import that’s only ever used as a type must say so explicitly:
// @skip-verify multi-file example -- ./types.ts exports `interface User` and `function makeUser`; this is ./main.tsimport { User, makeUser } from "./types"; // mixes a type and a value in one importconst u: User = makeUser(1, "Ada");That fails under verbatimModuleSyntax with TS1484: 'User' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled. (verified in a real two-file scratch project — tsc --verbatimModuleSyntax --strict main.ts against that exact ./types.ts). Splitting the import fixes it and documents intent for free:
// @skip-verify multi-file example -- ./types.ts exports `interface User` and `function makeUser`import { makeUser } from "./types";import type { User } from "./types"; // type-only import survives verbatimModuleSyntax
const u: User = makeUser(1, "Ada");The value of import type (and the matching export type) isn’t just satisfying a flag — a type-only import is guaranteed to be erased at emit, so it can never accidentally create a runtime dependency between modules that only need to share a shape. That matters most for barrel-file cycles: module a re-exports from module b’s barrel index.ts, which re-exports from module a’s barrel, and if either side pulled in the other for a type it only needed for annotations, a real runtime circular-import (undefined-at-import-time values, order-dependent breakage) can appear where only a compile-time type relationship was ever intended. Migrating a codebase to import type for anything that’s genuinely type-only removes that entire class of accidental runtime coupling — the compiler enforces, module by module, that a “just a type” import can never smuggle in a real dependency.
Migrating to TypeScript 6 and 7
Section titled “Migrating to TypeScript 6 and 7”The other migration you will do this year is upgrading an existing TypeScript project. 6.0 (March 2026) deprecated a set of options and changed the defaults; 7.0 (July 2026) removed the deprecated options and swapped in the native compiler. Do it in two hops:
- Upgrade to 6.0 first and add
"ignoreDeprecations": "6.0"totsconfig.json. The build works again immediately; the deprecations become warnings you can burn down at your own pace instead of a wall of errors. - Fix the defaults that moved under you. Set
"types": ["node", "jest", …]explicitly (the default is now[]), set"rootDir": "./src"if you relied on inference, and check thatstrict: trueandmodule: esnextare what you want — a CJS project setsmodule: commonjs(ornodenextwith"type": "commonjs") on purpose. - Replace each deprecated option: fold
baseUrlintopaths("@app/*": ["./src/app/*"]);moduleResolution: node→nodenextorbundler; dropoutFilein favour of a bundler; raisetargetoffes5and deletedownlevelIteration; deleteesModuleInterop/allowSyntheticDefaultImports/alwaysStrictlines set tofalse(they are always on now); changeimport … assert { type: "json" }towith { type: "json" }andmodule Foo {}tonamespace Foo {}. - Fix the code that depended on the old interop:
import * as express from "express"becomesimport express from "express", and any script that rantsc some-file.tsnext to a tsconfig needs--ignoreConfig. - Remove
ignoreDeprecations, then upgrade to 7.0. If the build is clean on 6.0 without it, 7.0 is a drop-in. Runtsc --stableTypeOrderingon 6.0 first if you diff.d.tsoutput in CI — 7 orders union members deterministically, and this flag shows you the new order before you switch. - Keep 6.0 around for tools that need the API.
typescript-eslint, Volar (Vue), and the Svelte/Astro/Angular language tooling still need the programmatic API, which 7.0 does not ship until 7.1. Install@typescript/typescript6alongside 7 and point those tools at it; in VS Code the two language servers coexist, and “Disable TypeScript 7 Language Server” falls back to 6 for a workspace that needs it.
Every hop is shippable, which is the same principle as the JS-to-TS path above: never be in a state where the build is broken for more than a day.
Authoring libraries
Section titled “Authoring libraries”If you publish a package, a few extra rules apply:
- Ship declaration files (
.d.ts). Set"declaration": trueso consumers get your types. Point"types"inpackage.jsonat the entry.d.ts. - Don’t leak internal types. Only your public API’s types are a contract; keep implementation types un-exported so you can change them freely.
- Treat types as semver. A change that breaks consumers’ type checking is a breaking change, even if the runtime behavior is identical. Widening a parameter is usually safe; narrowing it, or changing a return type, can break builds downstream.
Mental Model
Section titled “Mental Model”Every technique in this lesson exists to answer one question honestly at every point in a long migration: is this specific piece of code checked right now, or not — and can I tell? allowJs/checkJs extends “checked” as far as possible before any renaming happens. The strictness ladder is ordered so each wave of newly-checked code is real types, not anys that would hide the next wave’s errors. @ts-nocheck and @ts-expect-error are both honest opt-outs — deliberately visible, greppable, and in @ts-expect-error’s case, self-destructing once no longer needed — which is the opposite of an implicit any or a silently-stale @ts-ignore quietly widening the “not checked” set without anyone noticing. A migration doesn’t fail because any exists; it fails when nobody can tell anymore how much of the codebase any has quietly spread to.
Trade-offs
Section titled “Trade-offs”| Approach | Benefit | Cost |
|---|---|---|
JSDoc + checkJs on a .js file | Zero-runtime-cost typing, works without renaming or a build step change | More verbose than native TS syntax; some advanced type features are awkward or impossible to express |
Converting to .ts immediately | Full native syntax, no JSDoc verbosity | Requires a rename + build step change per file; riskier for files you’re not ready to fully commit to |
strict all at once | Fastest path to full safety, no ladder to maintain | Usually an unmanageable wall of errors on any real codebase — the reason this lesson recommends a ladder at all |
@ts-expect-error | Self-removes when the underlying type is fixed — can’t silently rot | Only suppresses one line; doesn’t scale to “this whole file isn’t ready” |
@ts-nocheck | Unblocks an entire stubborn file immediately | Hides every error in that file, including new ones introduced later — the bluntest tool in this lesson |
Common Misconceptions
Section titled “Common Misconceptions”checkJsrequires converting files to.tsfirst. It doesn’t — it type-checks plain.jsfiles using inference and JSDoc, with no renaming at all, which is exactly why it belongs at step zero.- The order you turn on
strict’s sub-flags doesn’t matter, as long as you get tostrict: trueeventually. It matters a great deal for how manageable each wave of errors is —noImplicitAnybeforestrictNullChecksmeans you’re narrowing real types, notanys, by the time the harder null-safety wave hits. @ts-nocheckand@ts-ignore/@ts-expect-errorare roughly the same tool at different scopes.@ts-expect-erroris self-correcting (it errors when no longer needed);@ts-ignoreand@ts-nocheckare not — both can rot silently, and@ts-nocheckdoes it across an entire file at once.import typeis just a style preference for cleaner code. UnderverbatimModuleSyntaxit’s enforced by the compiler, and more importantly, it’s a real guarantee against accidentally creating a runtime circular-import out of what was only ever meant to be a compile-time type relationship — especially across barrel files.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting a generated migration step or a “quick fix” for a type error, check for:
@ts-nocheckadded to make a stubborn file’s errors disappear, with no ticket reference or explanation — verify what real errors it’s actually suppressing before accepting it.strictsub-flags enabled all at once in a single generated diff on a large existing codebase — ask whether each wave of resulting errors was actually reviewed, or just bulk-suppressed to make the build pass.- A generated
import { Foo, useFoo }whereFoois only ever used as a type — underverbatimModuleSyntaxthis should be split, and if it isn’t, it’s a sign the generation didn’t actually run the type checker against the real config. - Any suggestion to “just use
@ts-ignore” for an error that’s actually a real, fixable type mismatch rather than a genuine limitation of the type system. - A
.d.ts-shipping library change described as “non-breaking” that actually narrows a parameter type or widens a return type — verify against the semver rule in this lesson, not the runtime behavior alone.
🐛 Spot the bug
// @ts-nocheckexport function parseConfig(raw) {const data = JSON.parse(raw);return { port: data.port, host: data.host.toLowerCase(),};}Show the answer
This file compiles with zero errors, and it will keep compiling with zero errors no matter what is broken inside it — that’s the entire point (and entire danger) of @ts-nocheck on the first line. data.host.toLowerCase() will throw at runtime if host is ever missing from the parsed config, data.port is completely untyped (it’s whatever JSON.parse produced, any), and if someone later adds a genuine typo (dat.port instead of data.port), the compiler will say nothing about that either — this file is entirely outside its protection, forever, until someone removes the directive. In a PR, a lone @ts-nocheck on a file with real, active logic like this (not generated code or a deletion candidate) should be treated as a request to justify why the file can’t be type-checked at all, not a routine way to silence an inconvenient error.