From JavaScript to TypeScript: Migration Without a Rewrite
Most production TypeScript was not born as TypeScript — it was a JavaScript codebase that converted one file at a time. This lesson teaches that process: why big-bang rewrites fail, how the compiler tolerates mixed codebases, and how strictness becomes a ratchet you tighten file by file.
Why rewrites fail and increments work
The instinct — "pause features, rewrite everything, resume" — fails for the same reason every big-bang rewrite fails: the business does not stop changing while the port happens, so the rewrite chases a moving target and ships neither. Incremental conversion inverts this: every merged change is shippable, value arrives with the first converted file, and the process can be paused indefinitely. TypeScript was explicitly designed for this — mixed JavaScript/TypeScript codebases are a supported configuration, not a workaround.
The three dials: allowJs, checkJs, and strictness tiers
Migration is governed by three compiler settings that loosen different things:
allowJs: true— the compiler includes.jsfiles in the program, so TypeScript code can import JavaScript code and vice versa. This is the dial that makes mixed codebases possible at all.checkJs: true— the compiler also checks the.jsfiles, inferring types from JSDoc and usage. An optional intermediate stage: your JavaScript gets checked before it even gets renamed.strict— the full strict family. Converted files inherit it; the standard trick for legacy code is a per-file opt-out (below) rather than a global downgrade.
Purpose of the example: the migration-ready tsconfig — permissive enough to include the legacy tree, strict enough that converted files get the full contract.
{
"compilerOptions": {
"allowJs": true, // include legacy .js in the program
"checkJs": false, // phase 2 upgrade: start checking the .js too
"strict": true,
"noEmit": true, // bundler still emits; tsc only verifies
"moduleResolution": "Bundler",
"target": "ES2022"
},
"include": ["src"]
}
What you should see: npx tsc --noEmit passes on day one — with allowJs and no conversion yet, there is nothing new to fail. The check gate is now live for every file you convert.
Converting the first file
Purpose of the example: the actual conversion loop, and the @ts-nocheck escape hatch for files that must move now but cannot be fixed yet.
git mv src/utils/format.js src/utils/format.ts
npx tsc --noEmit
What you should see: a finite list of errors in exactly one file. Fix them (usually by adding annotations where inference is insufficient), or — if the file is too noisy to fix in one sitting — park it honestly:
// format.ts — TEMPORARY: converted 2026-09-18, follow-up ticket TS-411
// @ts-nocheck
// ... existing code, now type-checked-while-disabled ...
What we learn: @ts-nocheck is discoverable and greppable, unlike a file simply being wrong. The migration metric becomes visible: count @ts-nocheck occurrences; drive them toward zero.
The strategy, in order
File choice determines migration speed. The ordering below is the one that keeps each step small:
Convert the perimeter first
Start with leaf modules — utilities, formatters, pure functions with few or no importers. Their conversions ripple into nothing. Order the work by dependency direction: things nothing depends on first, the app entry point last. Convert tests along with the module they cover — the tests double as the conversion's safety net, which is why the testing lesson precedes this one.
Handle untyped dependencies
Importing an untyped npm package produces the infamous TS7016: Could not find a declaration file. Three responses, in order of preference:
Purpose of the example: unblock imports without lying to the compiler.
// 1. Best: the community already typed it
// npm install --save-dev @types/left-pad
import pad from "left-pad";
// 2. Second: type just what you use, in a local .d.ts
// src/types/left-pad.d.ts
declare module "left-pad" {
function pad(str: string, len: number, char?: string): string;
export default pad;
}
// 3. Last resort: declare it any, with a comment and a ticket
// declare module "untyped-legacy-sdk"; // TS-412: type the 4 methods we call
What you should see: option 1 gives you full types with zero effort — always check TypeSearch first. Option 3 compiles, but imports the exact looseness the migration is trying to remove; it is a lease, not a solution.
Tightening strictness as a ratchet
Once everything is TypeScript, a second campaign begins: turning flags on that could not be on during migration. The professional pattern is the ratchet — enable a flag, fix what it breaks, and never allow the count to rise again:
# Current debt, tracked in CI like coverage:
grep -rc "@ts-nocheck\|@ts-ignore" src/ | wc -l # e.g. 37 — must not increase
npx tsc --noEmit --noUncheckedIndexedAccess 2>&1 | wc -l # e.g. 214 — target 0
What you should see: two numbers trending down over weeks. Ratchets turn migration from a project into a trajectory — and prevent the "we'll be strict someday" trap that permanently benches the value of the type system.
Practice: rehearse on a small codebase
Convert a three-file project
Take any small JavaScript project (or the demo files from this track's lab, merged into one folder): enable allowJs, run tsc --noEmit, then convert the leaf file, run again, convert the next. What you should see: the program stays runnable at every step — the definition of success for a migration. If a step forces you to convert two files at once, your ordering was wrong, and that observation is the lesson.
Write the migration plan
For a codebase you know, produce a one-page plan: the perimeter files in conversion order, the untyped dependencies and their @types status, the flags that stay off during phase one, and the two ratchet numbers. The architecture lesson shows what the codebase looks like when the campaign succeeds.