Command Palette

Search for a command to run...

Hectal
PHASE 15Intermediate ~25 min· topic 4 of 6

Topic 15.4

Migrating a JavaScript Codebase

In one line

You don't rewrite a JavaScript project in one go. You let TypeScript read the JavaScript, rename files one at a time, and turn the strict checks on one by one, so the project keeps working and the error count only goes down.

Think of it like this

Real-life example: Renovating a house you still live in. You don't tear it down and rebuild. You fix one room at a time, and each night the kitchen still works. A migration is the same: every day the app still builds and ships, and every week a few more rooms are "inspected" (type-checked). The only rule is never make a finished room worse.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Migration
Moving a project from one technology to another in steps, here from JavaScript to TypeScript.
allowJs
A tsconfig option that lets TypeScript include .js files in the project.
checkJs
A tsconfig option that makes TypeScript report type errors in .js files too.
@ts-check
A comment at the top of one JavaScript file that turns checking on for that file only.
@ts-nocheck
A comment that turns checking off for one file, useful for files you aren't ready to migrate.
JSDoc
Comments like /** @param {number} x */ that describe types inside JavaScript.
Leaf file
A file that imports nothing from your own project. Start converting here.
any budget
A number that limits how many anys the project may contain, lowered over time.
Ratchet
A rule that can only get stricter, never looser, like the click of a ratchet tool.
Codemod
A script that changes many source files automatically by understanding their syntax.

Step by step

01A JavaScript file with two bugs

Two functions and two calls, written the usual JavaScript way. The first call passes a string where a number is wanted ("100"), the second passes a misspelled field (nme).

src/price.jswhole filejs
export function addTax(price, rate) {
  return price + price * rate;
}

export function label(user) {
  return user.name.toUpperCase();
}

console.log(addTax("100", 0.18));
console.log(label({ nme: "Asha" }));

02Add a quiet tsconfig

allowJs and checkJs let TypeScript read and check the file, noEmit keeps it from writing output, and strict: false keeps the first run quiet. types: [] is the new default (Topic 11.4).

tsconfig.jsonwhole filejson
{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": true,
    "noEmit": true,
    "strict": false,
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "bundler",
    "types": []
  },
  "include": ["src"]
}

03Run tsc: quiet, then loud

With strict: false the file passes with no errors, even though it has two bugs: nothing is typed, so every parameter is any. Change strict to true and the first wall appears. This is the real output of TypeScript 7.0.2 on that file.

terminalwhole filebash
$ npx tsc -p .          # "strict": false
$                         # no output: exit code 0

$ npx tsc -p .          # "strict": true
src/price.js(1,24): error TS7006: Parameter 'price' implicitly has an 'any' type.
src/price.js(1,31): error TS7006: Parameter 'rate' implicitly has an 'any' type.
src/price.js(5,23): error TS7006: Parameter 'user' implicitly has an 'any' type.

04Types inside JavaScript: JSDoc and @ts-check

Add // @ts-check and JSDoc types to the file. The two real bugs now appear (even with checkJs: false, because of the comment), under the loose strict: false.

src/price.jswhole filejs
// @ts-check

/**
 * @param {number} price
 * @param {number} rate
 * @returns {number}
 */
export function addTax(price, rate) {
  return price + price * rate;
}

/** @typedef {{ name: string }} User */

/** @param {User} user */
export function label(user) {
  return user.name.toUpperCase();
}

console.log(addTax("100", 0.18));
console.log(label({ nme: "Asha" }));

05What the checker says now

The two bugs that were invisible are now found. This is the loop of a migration: add a type, find a bug, fix it, commit.

terminalwhole filebash
$ npx tsc -p .
src/price.js(19,20): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
src/price.js(20,21): error TS2561: Object literal may only specify known properties, but 'nme' does not exist in type 'User'. Did you mean to write 'name'?

06Count the budget

To put a ceiling on any, set the rule to warn and cap the number of warnings. In a scratch project (typescript-eslint 8.71.1 recommended, three anys in a file), the same command passes or fails only because of the number.

terminal (rule: no-explicit-any set to "warn")whole filebash
$ npx eslint src --max-warnings 2
src/legacy.ts
  1:30  warning  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any
  1:36  warning  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any
  4:21  warning  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any

✖ 3 problems (0 errors, 3 warnings)

ESLint found too many warnings (maximum: 2).
$ echo $?
1
$ npx eslint src --max-warnings 3     # same warnings listed
$ echo $?
0

07The order of the strict flags

Move one stage at a time and keep every stage green before the next. The diagram is a typical order; your project may differ.

The order of the strict flagsdiagram
Rendering diagram…

Try it yourself

  1. 1

    Walk the ladder

    In the first example, change the tsconfig note in your head: the example runs with strict: false. Now add : number to price and rate only, and label(user: { name: string }). Predict the two errors you will see, then compare with Step 1.

  2. 2

    Predict the flag

    In Step 2, which line would be an error if strictNullChecks were off? (None.) Which if it is on? Read the message. Then fix the function by returning string | undefined and using ?., as in Step 3.

  3. 3

    Start your own count

    In a real project, run grep -rEc ": any|as any" src and write the total at the top of an issue. Your first CI rule: this number may not go up.

Code & diagrams

Step 0: renamed to .ts, loose options, nothing checked

This example overrides the compiler options to { strict: false, exactOptionalPropertyTypes: false } (strict: false alone gives TS5052). Everything is any, so tsc reports nothing, and both bugs reach the console. Remove the tsconfig line mentally: with the editor defaults you would see three TS7006 errors.

Console

(nothing was printed)

Expected console output

10018
Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')
Step 1: add real types to the parts you touch

Typing price and user is enough to reveal both bugs. rate is still an implicit any, and the loose options don't complain. Turn that on in the next step.

Console

(nothing was printed)

Expected console output

10018
Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')
Step 2: strictNullChecks, one flag on its own

find returns T | undefined, but with strictNullChecks off the | undefined is invisible. This example turns only that flag on. The second call crashes at runtime exactly where the error points.

Console

(nothing was printed)

Expected console output

VITE
Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')
Step 3: the finished file, all defaults on

The same logic, now honest: the bugs were fixed where they entered, firstLong says it may return undefined, and the file passes with no overrides. The last line prints undefined for the empty case instead of crashing.

Console

(nothing was printed)

Expected console output

118
ASHA
VITE undefined

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Turn on noImplicitAny too early

In the first example, remove the tsconfig override (use the editor defaults) or, in a project, add "noImplicitAny": true to the loose options.

terminal
$ Types panel (first example with noImplicitAny on)
── what you'll see ──
index.ts(2,17): error TS7006: Parameter 'price' implicitly has an 'any' type.
index.ts(2,24): error TS7006: Parameter 'rate' implicitly has an 'any' type.
index.ts(6,16): error TS7006: Parameter 'user' implicitly has an 'any' type.

Break #2

Check a whole JavaScript project with strict on

In the scratch project, set "strict": true with allowJs and checkJs.

terminal
$ npx tsc -p .
── what you'll see ──
src/price.js(1,24): error TS7006: Parameter 'price' implicitly has an 'any' type.
src/price.js(1,31): error TS7006: Parameter 'rate' implicitly has an 'any' type.
src/price.js(5,23): error TS7006: Parameter 'user' implicitly has an 'any' type.

Break #3

Spend the budget

In a project whose rule is no-explicit-any: "warn", add one more any and run npx eslint src --max-warnings 2 with a budget of two.

terminal
$ npx eslint src --max-warnings 2
── what you'll see ──
ESLint found too many warnings (maximum: 2).

Myth vs fact

Myth

A migration means rewriting everything in one pull request.

Fact

TypeScript is built for gradual adoption: allowJs mixes both languages, and you can move one file at a time while the app keeps shipping.

Myth

// @ts-ignore is a quick way to finish a migration.

Fact

It hides errors and never reports itself. // @ts-expect-error at least tells you when it is no longer needed.

Myth

Once a file is .ts it is type safe.

Fact

A .ts file full of any and assertions is as safe as JavaScript with extra steps. Track the any count and the strict flags, not just the extension.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Rename in a commit that changes nothing else. Git detects renames by content similarity; a rename plus edits can look like a delete and an add, and git blame loses the file's history.

  • ▸

    strict: false plus selected flags equals the same compiler as strict: true minus the flags you left off, so you can raise each flag in a separate pull request and find regressions fast. TypeScript 6.0 made strict default to true, so a migrated project should write "strict": false explicitly while it is in the middle.

  • ▸

    exactOptionalPropertyTypes needs strictNullChecks (without it strict: false alone fails with TS5052, as you saw), and noUncheckedIndexedAccess is easier to add after strictNullChecks because it produces | undefined types that only matter once null checks are on.

  • ▸

    Large teams use *per-directory* tsconfigs with extends and project references (Topic 12.4): converted packages get the strict config, legacy ones keep the loose config, and CI runs both.

Remember this

  1. 1

    The problem this solves: you have a working JavaScript project, maybe thousands of files, and you want TypeScript's safety without stopping the team for months. Switching everything on at once buries you in errors. A migration is a plan that moves in small steps, each of which keeps the project building.

  2. 2

    Recall before you start. Topic 11.6: allowJs, checkJs and // @ts-check check JavaScript files. Topic 12.2: strict is a bundle of flags, each of which you can turn on alone. Topic 1.6: an unannotated parameter is an implicit any (and noImplicitAny reports it). Topic 15.2 gave you the lint rule that counts any.

  3. 3

    Step 1: let TypeScript read your JavaScript. Add a tsconfig.json with allowJs: true (TypeScript accepts .js files too) and checkJs: true (and reports errors in them). Start with strict: false so the first run is quiet. On the scratch project below the original JavaScript produced no errors with strict: false, and the same code under strict: true produced three TS7006 errors. That difference is exactly the size of the job.

  4. 4

    Step 2: check a few files first. checkJs on a whole old codebase can show thousands of errors. Instead, keep checkJs: false and put // @ts-check at the top of the files you are ready for. Real output: with checkJs: false, a file with // @ts-check and JSDoc types still reported its two errors; files without the comment stayed silent. The opposite tool, // @ts-nocheck, silences a file you aren't ready for when checkJs is on.

  5. 5

    JSDoc (comments like /** @param {number} price */) lets you write types inside JavaScript, with no build step. TypeScript reads them in checked JS files. It is useful for the first steps and for files that must stay .js (config files, scripts). Topic 11.6 covers it in depth. TypeScript 7 changed the JSDoc rules (for example @enum and Closure-style function types are gone), so prefer the plain @param, @returns, @typedef forms.

  6. 6

    Step 3: rename file by file, from the leaves. A *leaf* is a file that imports nothing from your own code (a helper, a constants file). Rename price.js to price.ts, add types to the exported functions, fix what the checker reports, commit. Work towards the entry points, so each file you convert already has typed imports. Rename in one commit with no other change, so Git sees a rename and keeps history.

  7. 7

    **Step 4: keep an any budget.** In a big migration you will write any in places you will return to. Count them and make the count a number that may only fall. The scratch run below used the lint rule no-explicit-any as "warn" and eslint --max-warnings 3: with three anys and a limit of 2 it exits 1, with a limit of 3 it exits 0. Lower the limit each time you remove some. This is how the ratchet works in CI (CI is the server that runs your checks on every change; the rule comes from Topic 15.2).

  8. 8

    Step 5: turn strict flags on one at a time. strict is a bundle (Topic 12.2). With strict: false you can enable the flags separately: usually noImplicitAny first (it forces a type on every parameter), then strictNullChecks (the biggest: every undefined becomes visible), then the rest, and last noUncheckedIndexedAccess and exactOptionalPropertyTypes, which are extra. The four live examples below are this exact journey on one file. When all flags are on, replace the list with strict: true.

  9. 9

    Tools help but don't finish the job. A codemod is a script that edits many files by understanding their syntax: jscodeshift (17.4.0) and ts-morph (28.0.0) are the usual libraries. ts-migrate, Airbnb's migration tool, was last published in November 2022 (0.1.35 on npm), so check that it still fits your versions. Codemods can rename files and insert any or // @ts-expect-error comments to get you to green; the real value, the types, still comes from people reading the code.

  10. 10

    A common trap: **@ts-ignore everywhere.** It makes the error count zero without removing a single bug. Prefer // @ts-expect-error: reason, which complains when the error goes away (Topic 15.3), so the comments clean themselves up as you improve the code.

Explain it without notes

01

How do allowJs, checkJs, // @ts-check and // @ts-nocheck differ?

02

In what order would you enable the strict flags in a legacy project, and why?

03

What is an any budget and how do you enforce it?

04

Why is @ts-expect-error better than @ts-ignore during a migration?

Practice

01

Convert this JavaScript function to a JSDoc-typed function (still .js, with // @ts-check) so that average("3") is reported (and say what average([]) does): ``js function average(nums) { return nums.reduce((a, b) => a + b, 0) / nums.length; }

02

Write the three tsconfig fragments for the stages: (1) JS allowed and quiet, (2) only noImplicitAny on, (3) only strictNullChecks added. Which compiler option makes exactOptionalPropertyTypes fail if you forget it?

Trade-offs

  • ↔

    Gradual migration keeps the product shipping but leaves a long period where half the project is checked and half is not. Put the progress (strict flags on, any count) where the team sees it.

  • ↔

    Automated tools can convert hundreds of files in an afternoon, but with any and suppression comments. They move the work, they don't remove it: schedule the follow-up.

Done when you can

  • I can set up allowJs and checkJs and explain what each does.

  • I can use // @ts-check and JSDoc to check one JavaScript file.

  • I can list the order in which I would enable strict flags and why.

  • I can enforce an any budget in CI.

  • I can explain why @ts-expect-error beats @ts-ignore.