|$ curl https://forge-ai.dev/api/markdown?path=docs/typescript/migration
$cat docs/typescript-—-js→ts-migration-playbook.md
updated Today·22 min read·published

TypeScript — JS→TS Migration Playbook

TypeScriptMigrationToolingIntermediate🎯Free Tools
Introduction

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

The finish line is strict: true with no unjustified any — not merely renaming files to .ts.
Migration Playbook
PhaseActions
0. BaselineAdd tsconfig, CI typecheck optional
1. allowJsCompile mixed tree; no renames yet
2. checkJsJSDoc types on hot modules
3. Rename.js → .ts/.tsx by directory
4. Strict laddernoImplicitAny → strictNullChecks → full strict
5. CleanupRemove allowJs; ESM; branded boundaries
phase1-tsconfig.json
JSON
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 & checkJs

allowJs lets TypeScript include JS files in the program. checkJs type-checks them using inference and JSDoc.

user.js
JavaScript
1// @ts-check
2/**
3 * @param {string} id
4 * @returns {Promise<{ id: string, name: string } | null>}
5 */
6export async function getUser(id) {
7 if (!id) return null;
8 return { id, name: "Ada" };
9}
10
📝

note

File-level // @ts-check enables checking even when checkJs is false.
Renaming Files Safely
rename.sh
Bash
1# Convert a folder at a time
2git mv src/utils/string.js src/utils/string.ts
3pnpm 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

Under NodeNext, import specifiers often need .js extensions even from TypeScript sources.
Gradual Strict
strict-ladder.json
JSON
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
FlagCommon fallout
noImplicitAnyParams need annotations
strictNullChecksMaybe null handling
strictPropertyInitializationClass fields definite assign
noUncheckedIndexedAccessindex signatures | undefined

best practice

Enable one flag per PR on large codebases. Keep metrics: error count trending down.
Converting CommonJS
before.cjs
JavaScript
1const fs = require("fs");
2module.exports = { read: (p) => fs.readFileSync(p, "utf8") };
3
after.ts
TypeScript
1import fs from "node:fs";
2
3export function read(p: string): string {
4 return fs.readFileSync(p, "utf8");
5}
6
interop.ts
TypeScript
1import pkg from "legacy-cjs"; // esModuleInterop / NodeNext
2import * as legacy from "legacy-cjs";
3

Set esModuleInterop / use NodeNext carefully. For dual packages, prefer clear exports maps.

any Budget & Escape Hatches
escape.ts
TypeScript
1// Temporary during migration — ticketed
2export type TODO_any = any; // eslint may still flag
3
4export function unsafeFromApi(data: unknown) {
5 return data as TODO_any;
6}
7

danger

Unbounded any freezes migration progress. Track a burn-down list.
Tooling During Migration
tools.sh
Bash
1pnpm add -D typescript @types/node
2pnpm exec tsc --noEmit
3pnpm add -D typescript-eslint
4# optional codemods
5pnpm dlx ts-migrate init .
6

Pair with the ESLint and Strict guides. Add Zod at API boundaries even before the core is fully typed.

Practice Snippets

Snippet 1 — allowJs

Practice snippet 1. Predict the resulting type, then confirm with hover types in your editor.

migration-1.ts
TypeScript
1{ "allowJs": true, "noEmit": true }

Snippet 2 — ts-check

Practice snippet 2. Predict the resulting type, then confirm with hover types in your editor.

migration-2.ts
TypeScript
1// @ts-check
2/** @param {string} x */
3export function f(x) { return x; }

Snippet 3 — rename

Practice snippet 3. Predict the resulting type, then confirm with hover types in your editor.

migration-3.ts
TypeScript
1git mv file.js file.ts

Snippet 4 — noImplicitAny

Practice snippet 4. Predict the resulting type, then confirm with hover types in your editor.

migration-4.ts
TypeScript
1function f(x: string) { return x; }

Snippet 5 — strictNullChecks

Practice snippet 5. Predict the resulting type, then confirm with hover types in your editor.

migration-5.ts
TypeScript
1function 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.

migration-6.ts
TypeScript
1import fs from "node:fs";
2export 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.

migration-7.ts
TypeScript
1import { util } from "./util.js";

Snippet 8 — @ts-expect-error

Practice snippet 8. Predict the resulting type, then confirm with hover types in your editor.

migration-8.ts
TypeScript
1// @ts-expect-error legacy
2legacy();

Snippet 9 — types package

Practice snippet 9. Predict the resulting type, then confirm with hover types in your editor.

migration-9.ts
TypeScript
1pnpm add -D @types/lodash

Snippet 10 — burn down any

Practice snippet 10. Predict the resulting type, then confirm with hover types in your editor.

migration-10.ts
TypeScript
1grep -R "\\bany\\b" src | wc -l
📝

note

These snippets are intentionally dense. Prefer understanding one fully over skimming all.
Deep Dive — JS→TS Migration

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

Keep npx tsc --noEmit green while experimenting — type errors are the curriculum.
Recipe — JSDoc generics

Recipe 1: JSDoc generics. Adapt names to your domain; keep the type relationships intact.

jsdoc.js
TypeScript
1/**
2 * @template T
3 * @param {T[]} items
4 * @returns {T | undefined}
5 */
6export function first(items) {
7 return items[0];
8}
9
📝

note

Verify recipe 1 by hovering inferred types and attempting an intentional misuse.
Recipe — Declaration emit for JS

Recipe 2: Declaration emit for JS. Adapt names to your domain; keep the type relationships intact.

allowjs-decl.json
TypeScript
1{ "compilerOptions": { "allowJs": true, "declaration": true, "emitDeclarationOnly": true } }
📝

note

Verify recipe 2 by hovering inferred types and attempting an intentional misuse.
Recipe — Per-folder tsconfig

Recipe 3: Per-folder tsconfig. Adapt names to your domain; keep the type relationships intact.

src-legacy-tsconfig.json
TypeScript
1{ "extends": "../tsconfig.json", "compilerOptions": { "strict": false }, "include": ["./**/*"] }
📝

note

Verify recipe 3 by hovering inferred types and attempting an intentional misuse.
Recipe — Codemod checklist

Recipe 4: Codemod checklist. Adapt names to your domain; keep the type relationships intact.

checklist.ts
TypeScript
1// 1 rename 2 fix imports 3 annotate exports 4 enable strictNullChecks
2
📝

note

Verify recipe 4 by hovering inferred types and attempting an intentional misuse.
FAQ

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.

QuestionShort 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
Checklist
ItemStatus
Strict tsconfig enabled
Boundaries validated at runtime
Public generics documented for variance
Lint type-checked rules on
No casual any / ts-ignore

best practice

Turn this checklist into a PR template for TypeScript-heavy changes.
Summary

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

Teach teammates the failure modes first — correct code is easier after you have seen the bugs.
Deep Dive — migration

This deep dive expands migration with production-oriented recipes. Skim the tables, then implement one recipe in a scratch file under strict mode.

info

Keep npx tsc --noEmit green while experimenting — type errors are the curriculum.
Recipe — Core pattern

Recipe 1: Core pattern. Adapt names to your domain; keep the type relationships intact.

migration-a.ts
TypeScript
1export type Flag = "on" | "off";
2export function toggle(f: Flag): Flag {
3 return f === "on" ? "off" : "on";
4}
5
📝

note

Verify recipe 1 by hovering inferred types and attempting an intentional misuse.
Recipe — Boundary parse

Recipe 2: Boundary parse. Adapt names to your domain; keep the type relationships intact.

migration-b.ts
TypeScript
1export function parse(input: unknown): string {
2 if (typeof input !== "string") throw new Error("string");
3 return input;
4}
5
📝

note

Verify recipe 2 by hovering inferred types and attempting an intentional misuse.
Recipe — Exhaustive

Recipe 3: Exhaustive. Adapt names to your domain; keep the type relationships intact.

migration-c.ts
TypeScript
1function assertNever(x: never): never { throw new Error(String(x)); }
2type K = "a" | "b";
3export 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

Verify recipe 3 by hovering inferred types and attempting an intentional misuse.
Recipe — Readonly params

Recipe 4: Readonly params. Adapt names to your domain; keep the type relationships intact.

migration-d.ts
TypeScript
1export function sum(nums: readonly number[]): number {
2 return nums.reduce((a, b) => a + b, 0);
3}
4
📝

note

Verify recipe 4 by hovering inferred types and attempting an intentional misuse.
FAQ

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.

QuestionShort 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
Checklist
ItemStatus
Strict tsconfig enabled
Boundaries validated at runtime
Public generics documented for variance
Lint type-checked rules on
No casual any / ts-ignore

best practice

Turn this checklist into a PR template for TypeScript-heavy changes.
Summary

You covered migration end-to-end: core mental model, recipes, FAQ, and a ship checklist. Continue with related topics via PageNav.

🔥

pro tip

Teach teammates the failure modes first — correct code is easier after you have seen the bugs.
Worked Example

End-to-end worked example for migration. Copy into a scratch project with strict: true.

migration-worked.ts
TypeScript
1export type Flag = "on" | "off";
2export 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

If the example feels large, extract types into a dedicated file and keep runtime logic thin.
StepPass criteria
Compiles under strictNo errors
Intentional misuse fails@ts-expect-error lights up
Runtime path testedVitest or node assert
Docs updatedREADME / PR note
migration-type-test.ts
TypeScript
1import type { Expect, Equal } from "./type-tests";
2
3// Local helpers if you lack a type-test lib:
4type Equal<A, B> =
5 (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
6type Expect<T extends true> = T;
7
8type _smoke = Expect<Equal<true, true>>;
9// Topic: migration
10

best practice

Keep type tests in CI alongside unit tests — they are documentation that cannot rot silently.
Production Notes

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.

verify.sh
Bash
1npx tsc --noEmit
2npx eslint .
3git diff --exit-code # ensure no accidental emit
4

warning

Never commit skipLibCheck: false flips without measuring CI time — but do not use skipLibCheck to hide broken local types.

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.

package-engines.json
JSON
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

Track error counts in CI comments so migrations stay visible to the whole team.
$Blueprint — Engineering Documentation·Section ID: TS-MIGRATION·Revision: 1.0

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.