TypeScript — JS→TS Migration Playbook
Migrating JavaScript to TypeScript is a campaign, not a weekend rewrite. Use allowJs, checkJs, gradual strictness, and mechanical CommonJS→ESM moves to keep the product shipping.
info
| Phase | Actions |
|---|---|
| 0. Baseline | Add tsconfig, CI typecheck optional |
| 1. allowJs | Compile mixed tree; no renames yet |
| 2. checkJs | JSDoc types on hot modules |
| 3. Rename | .js → .ts/.tsx by directory |
| 4. Strict ladder | noImplicitAny → strictNullChecks → full strict |
| 5. Cleanup | Remove allowJs; ESM; branded boundaries |
| 1 | { |
| 2 | "compilerOptions": { |
| 3 | "target": "ES2022", |
| 4 | "module": "NodeNext", |
| 5 | "moduleResolution": "NodeNext", |
| 6 | "allowJs": true, |
| 7 | "checkJs": false, |
| 8 | "noEmit": true, |
| 9 | "strict": false, |
| 10 | "skipLibCheck": true |
| 11 | }, |
| 12 | "include": ["src"] |
| 13 | } |
| 14 |
allowJs lets TypeScript include JS files in the program. checkJs type-checks them using inference and JSDoc.
| 1 | // @ts-check |
| 2 | /** |
| 3 | * @param {string} id |
| 4 | * @returns {Promise<{ id: string, name: string } | null>} |
| 5 | */ |
| 6 | export async function getUser(id) { |
| 7 | if (!id) return null; |
| 8 | return { id, name: "Ada" }; |
| 9 | } |
| 10 |
note
| 1 | # Convert a folder at a time |
| 2 | git mv src/utils/string.js src/utils/string.ts |
| 3 | pnpm exec tsc --noEmit |
| 4 | # fix errors, commit, continue |
| 5 |
For React, rename to .tsx when JSX is present. Update imports if your resolution requires extensions (NodeNext).
warning
| 1 | { |
| 2 | "compilerOptions": { |
| 3 | "noImplicitAny": true, |
| 4 | "strictNullChecks": true, |
| 5 | "strictFunctionTypes": true, |
| 6 | "strictBindCallApply": true, |
| 7 | "strictPropertyInitialization": true, |
| 8 | "noImplicitThis": true, |
| 9 | "alwaysStrict": true, |
| 10 | "strict": true |
| 11 | } |
| 12 | } |
| 13 |
| Flag | Common fallout |
|---|---|
| noImplicitAny | Params need annotations |
| strictNullChecks | Maybe null handling |
| strictPropertyInitialization | Class fields definite assign |
| noUncheckedIndexedAccess | index signatures | undefined |
best practice
| 1 | const fs = require("fs"); |
| 2 | module.exports = { read: (p) => fs.readFileSync(p, "utf8") }; |
| 3 |
| 1 | import fs from "node:fs"; |
| 2 | |
| 3 | export function read(p: string): string { |
| 4 | return fs.readFileSync(p, "utf8"); |
| 5 | } |
| 6 |
| 1 | import pkg from "legacy-cjs"; // esModuleInterop / NodeNext |
| 2 | import * as legacy from "legacy-cjs"; |
| 3 |
Set esModuleInterop / use NodeNext carefully. For dual packages, prefer clear exports maps.
| 1 | // Temporary during migration — ticketed |
| 2 | export type TODO_any = any; // eslint may still flag |
| 3 | |
| 4 | export function unsafeFromApi(data: unknown) { |
| 5 | return data as TODO_any; |
| 6 | } |
| 7 |
danger
| 1 | pnpm add -D typescript @types/node |
| 2 | pnpm exec tsc --noEmit |
| 3 | pnpm add -D typescript-eslint |
| 4 | # optional codemods |
| 5 | pnpm dlx ts-migrate init . |
| 6 |
Pair with the ESLint and Strict guides. Add Zod at API boundaries even before the core is fully typed.
Snippet 1 — allowJs
Practice snippet 1. Predict the resulting type, then confirm with hover types in your editor.
| 1 | { "allowJs": true, "noEmit": true } |
Snippet 2 — ts-check
Practice snippet 2. Predict the resulting type, then confirm with hover types in your editor.
| 1 | // @ts-check |
| 2 | /** @param {string} x */ |
| 3 | export function f(x) { return x; } |
Snippet 3 — rename
Practice snippet 3. Predict the resulting type, then confirm with hover types in your editor.
| 1 | git mv file.js file.ts |
Snippet 4 — noImplicitAny
Practice snippet 4. Predict the resulting type, then confirm with hover types in your editor.
| 1 | function f(x: string) { return x; } |
Snippet 5 — strictNullChecks
Practice snippet 5. Predict the resulting type, then confirm with hover types in your editor.
| 1 | function len(s: string | null) { |
| 2 | return s?.length ?? 0; |
| 3 | } |
Snippet 6 — CJS to ESM
Practice snippet 6. Predict the resulting type, then confirm with hover types in your editor.
| 1 | import fs from "node:fs"; |
| 2 | export const read = (p: string) => fs.readFileSync(p, "utf8"); |
Snippet 7 — NodeNext ext
Practice snippet 7. Predict the resulting type, then confirm with hover types in your editor.
| 1 | import { util } from "./util.js"; |
Snippet 8 — @ts-expect-error
Practice snippet 8. Predict the resulting type, then confirm with hover types in your editor.
| 1 | // @ts-expect-error legacy |
| 2 | legacy(); |
Snippet 9 — types package
Practice snippet 9. Predict the resulting type, then confirm with hover types in your editor.
| 1 | pnpm add -D @types/lodash |
Snippet 10 — burn down any
Practice snippet 10. Predict the resulting type, then confirm with hover types in your editor.
| 1 | grep -R "\\bany\\b" src | wc -l |
note
This deep dive expands JS→TS Migration with production-oriented recipes. Skim the tables, then implement one recipe in a scratch file under strict mode.
info
Recipe 1: JSDoc generics. Adapt names to your domain; keep the type relationships intact.
| 1 | /** |
| 2 | * @template T |
| 3 | * @param {T[]} items |
| 4 | * @returns {T | undefined} |
| 5 | */ |
| 6 | export function first(items) { |
| 7 | return items[0]; |
| 8 | } |
| 9 |
note
Recipe 2: Declaration emit for JS. Adapt names to your domain; keep the type relationships intact.
| 1 | { "compilerOptions": { "allowJs": true, "declaration": true, "emitDeclarationOnly": true } } |
note
Recipe 3: Per-folder tsconfig. Adapt names to your domain; keep the type relationships intact.
| 1 | { "extends": "../tsconfig.json", "compilerOptions": { "strict": false }, "include": ["./**/*"] } |
note
Recipe 4: Codemod checklist. Adapt names to your domain; keep the type relationships intact.
| 1 | // 1 rename 2 fix imports 3 annotate exports 4 enable strictNullChecks |
| 2 |
note
Does this replace runtime checks?
No. TypeScript erase at compile time. Pair with Zod or hand validation at boundaries.
How do I migrate gradually?
Enable strict flags one at a time, fix a package, then expand. See the migration guide.
What about performance?
Prefer project references and narrow include globs. Avoid enormous declaration merges in hot paths of the checker.
| Question | Short answer |
|---|---|
| Runtime cost of brands/variance annotations? | Zero — compile-time only |
| Need emit? | Often noEmit / bundler handles emit |
| CI gate? | tsc --noEmit + ESLint type-checked |
| Item | Status |
|---|---|
| Strict tsconfig enabled | ☐ |
| Boundaries validated at runtime | ☐ |
| Public generics documented for variance | ☐ |
| Lint type-checked rules on | ☐ |
| No casual any / ts-ignore | ☐ |
best practice
You covered JS→TS Migration end-to-end: core mental model, recipes, FAQ, and a ship checklist. Continue with related topics via PageNav.
pro tip
This deep dive expands migration with production-oriented recipes. Skim the tables, then implement one recipe in a scratch file under strict mode.
info
Recipe 1: Core pattern. Adapt names to your domain; keep the type relationships intact.
| 1 | export type Flag = "on" | "off"; |
| 2 | export function toggle(f: Flag): Flag { |
| 3 | return f === "on" ? "off" : "on"; |
| 4 | } |
| 5 |
note
Recipe 2: Boundary parse. Adapt names to your domain; keep the type relationships intact.
| 1 | export function parse(input: unknown): string { |
| 2 | if (typeof input !== "string") throw new Error("string"); |
| 3 | return input; |
| 4 | } |
| 5 |
note
Recipe 3: Exhaustive. Adapt names to your domain; keep the type relationships intact.
| 1 | function assertNever(x: never): never { throw new Error(String(x)); } |
| 2 | type K = "a" | "b"; |
| 3 | export function f(k: K) { |
| 4 | switch (k) { |
| 5 | case "a": return 1; |
| 6 | case "b": return 2; |
| 7 | default: return assertNever(k); |
| 8 | } |
| 9 | } |
| 10 |
note
Recipe 4: Readonly params. Adapt names to your domain; keep the type relationships intact.
| 1 | export function sum(nums: readonly number[]): number { |
| 2 | return nums.reduce((a, b) => a + b, 0); |
| 3 | } |
| 4 |
note
Does this replace runtime checks?
No. TypeScript erase at compile time. Pair with Zod or hand validation at boundaries.
How do I migrate gradually?
Enable strict flags one at a time, fix a package, then expand. See the migration guide.
What about performance?
Prefer project references and narrow include globs. Avoid enormous declaration merges in hot paths of the checker.
| Question | Short answer |
|---|---|
| Runtime cost of brands/variance annotations? | Zero — compile-time only |
| Need emit? | Often noEmit / bundler handles emit |
| CI gate? | tsc --noEmit + ESLint type-checked |
| Item | Status |
|---|---|
| Strict tsconfig enabled | ☐ |
| Boundaries validated at runtime | ☐ |
| Public generics documented for variance | ☐ |
| Lint type-checked rules on | ☐ |
| No casual any / ts-ignore | ☐ |
best practice
You covered migration end-to-end: core mental model, recipes, FAQ, and a ship checklist. Continue with related topics via PageNav.
pro tip
End-to-end worked example for migration. Copy into a scratch project with strict: true.
| 1 | export type Flag = "on" | "off"; |
| 2 | export function toggle(f: Flag): Flag { |
| 3 | return f === "on" ? "off" : "on"; |
| 4 | } |
| 5 |
Step-by-step
1. Paste the snippet. 2. Introduce a deliberate type error. 3. Fix it without assertions. 4. Add a second consumer that should fail if variance/brands/refs are wrong.
5. Run npx tsc --noEmit. 6. Commit the learning as a unit test or type test with @ts-expect-error.
info
| Step | Pass criteria |
|---|---|
| Compiles under strict | No errors |
| Intentional misuse fails | @ts-expect-error lights up |
| Runtime path tested | Vitest or node assert |
| Docs updated | README / PR note |
| 1 | import type { Expect, Equal } from "./type-tests"; |
| 2 | |
| 3 | // Local helpers if you lack a type-test lib: |
| 4 | type Equal<A, B> = |
| 5 | (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false; |
| 6 | type Expect<T extends true> = T; |
| 7 | |
| 8 | type _smoke = Expect<Equal<true, true>>; |
| 9 | // Topic: migration |
| 10 |
best practice
Shipping notes teams hit in production when adopting these patterns: version skew between editor TypeScript and CI, incomplete include globs, and silent any from third-party DefinitelyTyped stubs.
| 1 | npx tsc --noEmit |
| 2 | npx eslint . |
| 3 | git diff --exit-code # ensure no accidental emit |
| 4 |
warning
Document peer dependency TypeScript ranges for libraries. For apps, pin the TypeScript version in the workspace and enable the workspace SDK in VS Code/Cursor.
| 1 | { |
| 2 | "devDependencies": { |
| 3 | "typescript": "~5.8.0" |
| 4 | }, |
| 5 | "packageManager": "pnpm@9" |
| 6 | } |
| 7 |
Rollback plan
If a strict flag floods errors, revert the flag, keep fixed files, and re-enable on a smaller glob via a nested tsconfig. Progress should be monotonic even when flags temporarily retreat.
pro tip
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.