Migrating JavaScript to TypeScript
Move a JavaScript app to TypeScript one file at a time: allowJs first, then renaming files, typing the edges, and raising strictness with a to-do list.
- 01Start without breaking anythingAdd a tsconfig.json with allowJs, so TypeScript reads a JavaScript project and the app keeps working.
- 02Convert files in a safe orderRename files to .ts one at a time, starting with the ones others import, and fix what each rename finds.
- 03Finish the jobType the edges where data comes in, then raise strictness, using @ts-expect-error as a to-do list.
A shop app that moves one file at a time
A small shop app is written in JavaScript. It has four files: format.js formats prices, api.js loads products from the server, cart.js adds up the cart, and checkout.js runs the checkout page. The team wants TypeScript, but they can't stop selling for a month to rewrite everything. Here are two of the files:
1export function cartTotal(cart) {2 return cart.reduce((sum, item) => sum + item.cents * item.quantity, 0);3}Line 1 exports cartTotal, which takes a cart. Line 2 adds up each item's cents times its quantity. Prices are in cents, so 300 means $3.00.
1import { cartTotal } from "./cart";2 3const cart = [{ name: "Tea", cents: 300, qty: 2 }];4const total = cartTotal(cart);5// ^?6console.log(total);Line 1 imports cartTotal from cart.js. With a bundler like Vite, an import can leave out the file's extension. Line 3 makes a cart with two teas, but spells the quantity qty. Line 4 adds it up, and line 6 prints NaN, "not a number", because item.quantity is undefined.
TypeScript is already checking both files, with the settings this lesson starts from (you'll write them in step 1). It reports nothing. The query on line 5 shows why: total is any, the type that switches checking off. cartTotal doesn't say what a cart is, so TypeScript can't tell that qty is wrong. This lesson moves the app to TypeScript in small steps, and the app keeps working after every one of them:
A family renovating their home doesn't move out for a year. They redo one room at a time and keep cooking and sleeping in the rest. They fix the pipes before the kitchen that uses them. Anything they can't do yet gets a sticky note, so nothing is forgotten.
- In real life: You keep living in the house during the work
- In TypeScript: The app keeps shipping during the migration
- In real life: You redo one room at a time
- In TypeScript: You rename one file at a time
- In real life: You start with the pipes the other rooms depend on
- In TypeScript: You start with the files the other files import
- In real life: A sticky note marks each job left for later
- In TypeScript: A
// @ts-expect-errorcomment marks each error left for later
Migrate in small steps that each leave the app working. Let TypeScript read the JavaScript, rename files one at a time, type the edges where data comes in, then raise strictness, marking what's left as to-dos.
Step 1: let TypeScript read the JavaScript
The first change touches no code at all. The project gets a tsconfig.json (Setting up a project covers the file):
1{2 "compilerOptions": {3 "allowJs": true,4 "checkJs": true,5 "strict": false,6 "noEmit": true,7 "target": "es2022",8 "module": "esnext",9 "moduleResolution": "bundler"10 },11 "include": ["src"]12}- Line 3,
allowJs, makes the.jsfiles part of the project, so.tsfiles can import them. - Line 4,
checkJs, reports errors inside.jsfiles too. Types in JavaScript files showed both options in detail. - Line 5 keeps strict checks off for now, so renaming a file doesn't bring a flood of errors.
- Line 6 says "check only, write no files". The bundler keeps building the app exactly as before.
- Lines 7 to 9 describe where the code runs (target, lib & module), and line 11 includes the
srcfolder.
Read each row of the table as: this setting, why it's there on day one, and when it changes.
| The setting | Why on day one | When it changes |
|---|---|---|
"allowJs": true | TypeScript reads the .js files, so .ts files can import them. | Remove it when no .js files are left. |
"checkJs": true | Errors in .js files are reported too. Leave it off if it finds too many at first. | It goes with allowJs at the end. |
"strict": false | Renaming a file doesn't bring a flood of new errors. | Step 4 turns the checks on one at a time. |
"noEmit": true | tsc only checks. The bundler keeps building the app as before. | It stays: the bundler writes the JavaScript. |
Bundlers like Vite remove types without checking them (Type checking vs type stripping explains why), so the team adds a script that runs the checker:
1{2 "scripts": {3 "dev": "vite",4 "build": "vite build",5 "typecheck": "tsc"6 }7}Lines 3 and 4 are the scripts the app already had. Line 5 adds typecheck: npm run typecheck runs tsc, which reads tsconfig.json, checks every file, and writes nothing. If checkJs finds more errors than the team can fix this week, they turn it off, or put // @ts-nocheck at the top of the noisiest files. Either way, the app hasn't changed.
Step 2: rename one file at a time
Now the files move, one at a time. Which one first? Look at what imports what, and start with the files that import nothing:
format.js and api.js import nothing, so they go first, and checkout.js goes last, when everything it imports already has types.Once a file has types, every file that imports it is checked against them, even files that are still JavaScript (with checkJs on). Try it with cart.js and the checkout from the overview:
1export function cartTotal(cart) {2 return cart.reduce((sum, item) => sum + item.cents * item.quantity, 0);3}1import { cartTotal } from "./cart";2 3const cart = [{ name: "Tea", cents: 300, qty: 2 }];4const total = cartTotal(cart);5// ^?6console.log(total);NaNconst total: anyNo errorsBoth files are JavaScript. cartTotal has no types, so the query on line 5 says total is any, and the misspelled qty on line 3 goes through. It prints NaN.
Step 2 only renames the file. With strict off, nothing new is reported, and checkout.js needs no change, because its import on line 1 has no extension. Step 3 adds a CartItem type to cart.ts, and the bug from the overview is found in checkout.js: line 4 passes items with qty, and CartItem asks for quantity. The query on line 5 now says total is a number. Step 4 fixes the spelling, and the total is 600.
Two habits keep each rename safe:
- Change only the name and the types. If a rename also rewrites code, a mistake is hard to spot. Make that a separate change.
- Merge each file on its own. A small change is quick to check, and the rest of the team keeps working.
What renaming finds
Some code that's fine in a .js file gets errors in a .ts file, even with strict off. TypeScript is more forgiving with JavaScript files, because so much JavaScript is written this way. Switch the same code between the two:
1function greet(name, greeting) {2 return `${greeting ?? "Hello"}, ${name}!`;3}4 5const order = {};6order.id = 7;7order.total = 750;8 9console.log(greet("Asha"));10console.log(order.id);No errorsHello, Asha! and 7As a JavaScript file, this is fine, even with checkJs on. In a .js file every parameter may be left out, and an empty object may get new properties later. It prints Hello, Asha! and 7.
As shop.js, there are no errors. Renamed to shop.ts, lines 6, 7, and 10 get Property 'id' does not exist on type '{}' (or 'total'): line 5 made order with no properties, so its type has none. Line 9 gets Expected 2 arguments, but got 1: in a .ts file every parameter is expected unless it's marked optional with ?. The fixed version builds the order in one go on line 5 and marks greeting? optional on line 1. It prints Hello, Asha! and 7, the same as before.
These two are the errors renaming finds most often. Neither is a bug in the running code; each is a place where the code didn't say what it meant. The fixes are small, and they don't change what the code does.
Step 3: type the edges first
An edge is a place where data enters your code from outside, or passes between files:
- a reply from the server, after
JSON.parseorresponse.json(); - values from
localStorage, a form field, or the URL; - the parameters of functions other files call, like
cartTotal.
JSON.parse and response.json() give back any, and so does a parameter with no type while strict is off, like cart in cart.js. any spreads to everything made from it. One type at the edge stops that. Here is api.ts, which turns the server's reply into products:
1type Product = { id: number; name: string; cents: number };2 3const reply = '[{ "id": 1, "name": "Tea", "cents": 300 }, { "id": 2, "name": "Cake", "cents": 450 }]';4const products = JSON.parse(reply);5// ^?6const labels = products.map((item) => `${item.nmae}: ${item.cents}`);7// ^?8console.log(labels.join(", "));undefined: 300, undefined: 450const products: anyconst labels: anyNo errorsJSON.parse gives back any, so products is any, and so is everything made from it: the query on line 7 says labels is any too. The typo nmae on line 6 goes through, and it prints undefined: 300, undefined: 450.
Line 1 describes a Product, and line 3 is the reply as text. With No type at the edge, line 4 gives products the type any, and the query on line 7 shows labels is any too, so the typo nmae on line 6 goes through. Product[] at the edge changes only line 4. You wrote one type, and TypeScript worked out that labels is a string[] and marked the typo: Property 'nmae' does not exist on type 'Product'. Typo fixed prints Tea: 300, Cake: 450.
Product[], and TypeScript works out the types of everything made from it.The type on line 4 is a promise about the reply, not a check: if the server sends something else, nothing stops it while the page runs. Typing fetch & JSON shows how to check a reply before you trust it. Even as a promise, it's worth writing first: the code inside your app gets real types from it, often with no annotations of its own.
Step 4: raise strictness with a to-do list
When every file is .ts, strict is still off. Strict mode options showed how to turn its checks on one at a time: noImplicitAny first, then strictNullChecks. In a big app, a check can find more errors than anyone can fix in one go. Waiting until they're all fixed leaves the check off for everyone, so new code gets the same mistakes. The other way is to turn the check on now and mark each leftover error as a to-do:
1type User = { name: string; email?: string };2const stock = new Map([["tea", 5], ["cake", 2]]);3 4function emailDomain(user: User) {5 return user.email.split("@")[1];6}7 8function stockLabel(product: string) {9 return stock.get(product).toString();10}11 12console.log(emailDomain({ name: "Asha", email: "asha@example.com" }));13console.log(stockLabel("tea"));0falseNo errorsexample.com, 5With strictNullChecks off, TypeScript trusts that email and stock.get always have a value. No errors, and the code prints example.com and 5.
Step 2 turns on strictNullChecks, and lines 5 and 9 are marked. Step 3 puts a // @ts-expect-error comment above each, with TODO(strict) and a reason. A comment like this, which gives TypeScript an instruction, is called a directive. It hides the error on the next line, and the check now protects every new line of code. Step 4 shows what makes this a good to-do list: someone fixes line 11, and TypeScript marks the comment on line 10 with Unused '@ts-expect-error' directive. Step 5 deletes it, and one to-do is left.
@ts-expect-error comment is one to-do. When the line under it is fixed, TypeScript marks the comment, so you delete it and the list gets shorter.To see the whole list, search the project for TODO(strict), with your editor's search or git grep. Each fix makes the list shorter. // @ts-ignore would hide errors too, but it never tells you when the error under it is gone, so the list would fill up with stale items (Types in JavaScript files compares the two).
When the last to-do is gone and no .js files are left, the tsconfig.json gets simpler:
1{2 "compilerOptions": {3 "strict": true,4 "noEmit": true,5 "target": "es2022",6 "module": "esnext",7 "moduleResolution": "bundler"8 },9 "include": ["src"]10}Line 3 turns on the whole strict group. allowJs, checkJs, and "strict": false are gone. Everything else is the same as on day one.
The shop's migration plan
Here is the whole plan for the shop app, as the team might write it down. Each row is one step that can be merged on its own. Read each row as: the step, what changes, and what stays the same.
| Step | What changes | What stays the same |
|---|---|---|
| 1 | Add tsconfig.json with allowJs, and a typecheck script. | Every file is still .js. The app builds and runs as before. |
| 2 | Rename format.js and api.js, which import nothing, and type them. | The files that import them keep their imports. |
| 3 | Rename cart.js, then checkout.js, fixing what each rename finds. | Each rename is its own small change. Nothing else is rewritten. |
| 4 | Turn on noImplicitAny, then strictNullChecks, marking leftovers as TODO(strict). | New code is checked from the day each flag goes on. |
| 5 | Fix the to-dos, swap in "strict": true, and remove allowJs. | The app does exactly what it did on day one. |
Each card below is one line from a .js file. Sort them by what happens when the file is renamed to .ts, with strict still off. Each card is checked on its own.
const order = {}; order.id = 7;function greet(name, greeting) { return greeting + name; } greet("Asha");const settings = { theme: "dark" }; settings.fontSize = 16;const order = { id: 7 }; order.id = 8;function greet(name, greeting = "Hi") { return greeting + name; } greet("Asha");const prices = [3, 4]; const total = prices.reduce((sum, price) => sum + price, 0);
Sort each line by what TypeScript says once it's in a .ts file, with strict off. None of them has an error as a .js file.
Common mix-ups
- "We have to stop and rewrite the app in TypeScript." No. With
allowJs, JavaScript and TypeScript files work side by side, and you move one file at a time while the app keeps shipping. - "Renaming a file to .ts changes what it does." No. The bundler removes the types, and the same JavaScript runs. Every example in this lesson prints the same output before and after.
- "allowJs checks my JavaScript files." No. It lets TypeScript read them, so their types reach the files that import them.
checkJsis what reports errors inside them. - "Start with the main file, since it matters most." No. The main file imports everything, so it would only see
any. Start with the files that import nothing. - "Once every file ends in .ts, the migration is done." No. With strict off,
anycan still hide mistakes. Raising strictness is the last step. - "A type on the server reply checks the reply." No. It's a promise TypeScript believes. Checking the reply while the page runs is your code's job.
Practice exercises
Each exercise lists the settings it's checked with under the code, and some name their own file, like report.ts. Press Edit & run to change the code: the errors update as you type.
report.ts imports firstName from an untyped users.js. What type does greeting get? Point at it, or add a // ^? line under it.
1import { firstName } from "./users";2 3const greeting = firstName({ name: "Asha Rao" });4console.log(greeting);1import { firstName } from "./users";2 3const greeting = firstName({ name: "Asha Rao" });4// ^?5console.log(greeting);firstName comes from users.js, which says nothing about user, so user.name.split(" ")[0] is any. The query on line 4 shows const greeting: any. It prints Asha, but TypeScript can't check anything done with greeting.
This file was just renamed from shipping.js to shipping.ts, with strict off. Which line will TypeScript mark? Then fix it.
function shippingFor(country, express) {
return express ? 10 : country === "IN" ? 0 : 5;
}
console.log(shippingFor("IN"));1function shippingFor(country: string, express?: boolean) {2 return express ? 10 : country === "IN" ? 0 : 5;3}4 5console.log(shippingFor("IN"));Line 5 passes one argument to a function with two parameters: Expected 2 arguments, but got 1. Typing the parameters and marking express? optional fixes it. It still prints 0.
settings.ts was renamed from JavaScript and gets four errors. Fix them without changing what it prints. What does it print?
1const settings = {};2settings.theme = "dark";3settings.fontSize = 16;4console.log(`${settings.theme} ${settings.fontSize}`);1const settings = { theme: "dark", fontSize: 16 };2console.log(`${settings.theme} ${settings.fontSize}`);{ theme: "dark", fontSize: 16 } has both properties from the start, so TypeScript's type for it has them too. It prints dark 16, as the starter did.
No errors, but the starter prints NaN. Add one type where the reply comes in, fix what TypeScript then finds, and say what the fixed code prints.
1type Product = { id: number; name: string; cents: number };2 3const reply = '[{ "id": 1, "name": "Tea", "cents": 300 }, { "id": 2, "name": "Cake", "cents": 450 }]';4const products = JSON.parse(reply);5const total = products.reduce((sum, item) => sum + item.cent, 0);6console.log(total);1type Product = { id: number; name: string; cents: number };2 3const reply = '[{ "id": 1, "name": "Tea", "cents": 300 }, { "id": 2, "name": "Cake", "cents": 450 }]';4const products: Product[] = JSON.parse(reply);5const total = products.reduce((sum, item) => sum + item.cents, 0);6console.log(total);Product[] on line 4 gives every item the type Product, so the typo item.cent on line 5 is marked. With item.cents, it adds 300 and 450 and prints 750. The starter printed NaN.
This file has strictNullChecks on. Which line does TypeScript mark, and why? Then fix it.
1const stock = new Map([["tea", 5]]);2 3function stockLabel(product: string) {4 // @ts-expect-error TODO(strict): a product that isn't in stock5 return (stock.get(product) ?? 0).toString();6}7 8console.log(stockLabel("jam"));1const stock = new Map([["tea", 5]]);2 3function stockLabel(product: string) {4 return (stock.get(product) ?? 0).toString();5}6 7console.log(stockLabel("jam"));Line 5 was fixed, so line 4's @ts-expect-error gets Unused '@ts-expect-error' directive. Deleting the comment clears the error and shortens the to-do list by one. It prints 0 for jam.
The project just turned strict on, and this file gets two errors on line 1. Give the parameters types without changing what the code does. What does it print?
1function lineTotal(item, quantity) {2 return item.cents * quantity;3}4 5console.log(lineTotal({ name: "Tea", cents: 300 }, 2));1type Product = { name: string; cents: number };2 3function lineTotal(item: Product, quantity: number) {4 return item.cents * quantity;5}6 7console.log(lineTotal({ name: "Tea", cents: 300 }, 2));With strict on, noImplicitAny reports both untyped parameters. A Product type for item and number for quantity fix both, and TypeScript checks the call on line 7 against them. It prints 600.
Check your understanding
Some questions name the settings they assume. The quiz shows its code without the checker, so the error list doesn't give the answers away.
Question 1 of 7What does
"allowJs": truedo at the start of a migration?Choose an answer to see the explanation.
Question 2 of 7A .ts file imports a function from cart.js, which has no types or JSDoc. What does TypeScript usually know about what it returns?
Choose an answer to see the explanation.
Question 3 of 7This file was renamed from order.js to order.ts, with strict off. What happens?
Read the code, then predictPop out in the code editor (opens in a new tab)TypeScriptconst order = {}; order.total = 750; console.log(order.total);Choose an answer to see the explanation.
Question 4 of 7Which file of the shop should you convert first?
Choose an answer to see the explanation.
Question 5 of 7With strictNullChecks on, someone fixed line 3. What does TypeScript say now?
Read the code, then predictPop out in the code editor (opens in a new tab)TypeScriptconst sizes = new Map([["S", 3]]); // @ts-expect-error TODO(strict): a size that isn't there console.log((sizes.get("M") ?? 0) + 1);Choose an answer to see the explanation.
Question 6 of 7Why type the edges first, like the reply from the server?
Choose an answer to see the explanation.
Question 7 of 7What should a commit that renames a file to .ts change?
Choose an answer to see the explanation.
Key takeaways
- A migration is a series of small steps, and the app keeps working after each one.
- Step 1 adds a tsconfig.json with
allowJs, strict off, andnoEmit, plus a script that runstsc. - Rename one file at a time, starting with the files that import nothing. Change only names and types, not what the code does.
- In a
.tsfile,{}has no properties and every parameter is expected, even with strict off. - Type the edges where data comes in first. One type there gives types to the code after it.
- Raise strictness one check at a time, marking leftovers with
@ts-expect-error TODO(strict). TypeScript tells you when one is done.
Remember the one-liner.
Let TypeScript in, rename from the bottom up, type the edges, then turn the checks on and work through the to-do list.
Coming next: Reading type errors. It reads long compiler messages from the bottom up, like the ones a migration brings, and fixes the most common ones.