cf.completefrontend
Open lab My progress
THE TYPESCRIPT FIELD GUIDE

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.

By the end, you can
  • 01
    Start without breaking anythingAdd a tsconfig.json with allowJs, so TypeScript reads a JavaScript project and the app keeps working.
  • 02
    Convert files in a safe orderRename files to .ts one at a time, starting with the ones others import, and fix what each rename finds.
  • 03
    Finish 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:

cart.jsJavaScript
export function cartTotal(cart) {  return cart.reduce((sum, item) => sum + item.cents * item.quantity, 0);}

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.

import { cartTotal } from "./cart"; const cart = [{ name: "Tea", cents: 300, qty: 2 }];const total = cartTotal(cart);//    ^?console.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:

Four cards joined by arrows: step 1 allowJs, TypeScript reads your JavaScript; step 2 rename, one file at a time; step 3 type the edges, where data comes in; step 4 strict, one check at a time.step 1step 2step 3step 4allowJsrenametype edgesstrictTypeScript readsyour JavaScriptone fileat a timewhere datacomes inone checkat a time
The four steps of this lesson. The app keeps working after every one of them, so the team can keep shipping while it moves.
Real-life analogyRenovating a home you live in

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-error comment marks each error left for later
The main idea

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):

tsconfig.json on day oneJSON
{  "compilerOptions": {    "allowJs": true,    "checkJs": true,    "strict": false,    "noEmit": true,    "target": "es2022",    "module": "esnext",    "moduleResolution": "bundler"  },  "include": ["src"]}
  • Line 3, allowJs, makes the .js files part of the project, so .ts files can import them.
  • Line 4, checkJs, reports errors inside .js files 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 src folder.

Read each row of the table as: this setting, why it's there on day one, and when it changes.

The day-one settings, and when each one changes
The settingWhy on day oneWhen it changes
"allowJs": trueTypeScript reads the .js files, so .ts files can import them.Remove it when no .js files are left.
"checkJs": trueErrors in .js files are reported too. Leave it off if it finds too many at first.It goes with allowJs at the end.
"strict": falseRenaming a file doesn't bring a flood of new errors.Step 4 turns the checks on one at a time.
"noEmit": truetsc 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:

package.json: a script that runs tscJSON
{  "scripts": {    "dev": "vite",    "build": "vite build",    "typecheck": "tsc"  }}

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:

Four files. checkout.js imports cart.js and api.js, and cart.js imports format.js. They are numbered in the order to convert them: 1 format.js, 2 api.js, 3 cart.js, 4 checkout.js.4. checkout.js3. cart.js2. api.js1. format.jsan arrow means importsconvert from the bottom up
An arrow means "imports". Convert from the bottom up: 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:

Playground: rename cart.js to cart.ts
cart.jsJavaScript
export function cartTotal(cart) {  return cart.reduce((sum, item) => sum + item.cents * item.quantity, 0);}
import { cartTotal } from "./cart"; const cart = [{ name: "Tea", cents: 300, qty: 2 }];const total = cartTotal(cart);//    ^?console.log(total);
checkout.js printsNaN
Line 5 showsconst total: any
checkout.jsNo errors
Step 1 of 4

Both 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.

Both files are checked for real with the shop's settings: allowJs and checkJs on, strict off. The output is what checkout.js prints. Reset goes back to step 1.

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:

Playground: the same code as .js and as .ts
function greet(name, greeting) {  return `${greeting ?? "Hello"}, ${name}!`;} const order = {};order.id = 7;order.total = 750; console.log(greet("Asha"));console.log(order.id);
TypeScript saysNo errors
PrintsHello, Asha! and 7
Try it yourself

As 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.

The code is checked for real as the file named in its title, with strict off and checkJs on. It prints the same two lines every time. Reset goes back to shop.js.

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.parse or response.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:

Playground: one type at the edge
api.ts: products from the serverPop out in the code editor (opens in a new tab)TypeScript
type Product = { id: number; name: string; cents: number }; const reply = '[{ "id": 1, "name": "Tea", "cents": 300 }, { "id": 2, "name": "Cake", "cents": 450 }]';const products = JSON.parse(reply);//    ^?const labels = products.map((item) => `${item.nmae}: ${item.cents}`);//    ^?console.log(labels.join(", "));
Printsundefined: 300, undefined: 450
Line 5 showsconst products: any
Line 7 showsconst labels: any
TypeScriptNo errors
Try it yourself

JSON.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.

The code is checked for real with strict off, and the output is what it prints. Lines 4 and 6 are the ones that change. Reset goes back to No type at the edge.

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.

A server window sends JSON text. An arrow leads to a card, products: Product[], labelled one type you write. Two arrows lead on to labels: string[] and item: Product, labelled types TypeScript works out.serverJSON textone type you writeproducts: Product[]labels: string[]item: Producttypes TypeScriptworks out
The reply arrives as text. You write one type where it comes in, 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:

Playground: strictNullChecks with a to-do list
users-and-stock.tsPop out in the code editor (opens in a new tab)TypeScript
type User = { name: string; email?: string };const stock = new Map([["tea", 5], ["cake", 2]]); function emailDomain(user: User) {  return user.email.split("@")[1];} function stockLabel(product: string) {  return stock.get(product).toString();} console.log(emailDomain({ name: "Asha", email: "asha@example.com" }));console.log(stockLabel("tea"));
To-dos left0
strictNullChecksfalse
TypeScriptNo errors
Printsexample.com, 5
Step 1 of 5

With strictNullChecks off, TypeScript trusts that email and stock.get always have a value. No errors, and the code prints example.com and 5.

Each step is checked for real with the settings the list under the code shows. The output never changes. Reset goes back to step 1.

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.

An editor window with two @ts-expect-error TODO(strict) comments. Line 5's still hides an error. Line 10's is marked Unused directive, because the line under it was fixed. An arrow leads to a card: 1 to-do left.editor5 @ts-expect-error TODO(strict)still hides an error below it10 @ts-expect-error TODO(strict)Unused '@ts-expect-error' directivedelete it, andthe list shrinks1 to-do left
Each @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:

tsconfig.json when the migration is doneJSON
{  "compilerOptions": {    "strict": true,    "noEmit": true,    "target": "es2022",    "module": "esnext",    "moduleResolution": "bundler"  },  "include": ["src"]}

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.

Moving the shop app to TypeScript
StepWhat changesWhat stays the same
1Add tsconfig.json with allowJs, and a typecheck script.Every file is still .js. The app builds and runs as before.
2Rename format.js and api.js, which import nothing, and type them.The files that import them keep their imports.
3Rename cart.js, then checkout.js, fixing what each rename finds.Each rename is its own small change. Nothing else is rewritten.
4Turn on noImplicitAny, then strictNullChecks, marking leftovers as TODO(strict).New code is checked from the day each flag goes on.
5Fix 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.

Error or fine after renaming?
  • 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);
Try it yourself
0 of 6 correct

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.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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. checkJs is 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, any can 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.

Exercise 1 · Warm-upRead the type of a JavaScript import

report.ts imports firstName from an untyped users.js. What type does greeting get? Point at it, or add a // ^? line under it.

Starter codePop out in the code editor (opens in a new tab)TypeScript
import { firstName } from "./users"; const greeting = firstName({ name: "Asha Rao" });console.log(greeting);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFind what the rename broke

    This file was just renamed from shipping.js to shipping.ts, with strict off. Which line will TypeScript mark? Then fix it.

    Starter codePop out in the code editor (opens in a new tab)TypeScript
    function shippingFor(country, express) {
      return express ? 10 : country === "IN" ? 0 : 5;
    }
    
    console.log(shippingFor("IN"));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeBuild the settings in one go

      settings.ts was renamed from JavaScript and gets four errors. Fix them without changing what it prints. What does it print?

      Starter codePop out in the code editor (opens in a new tab)TypeScript
      const settings = {};settings.theme = "dark";settings.fontSize = 16;console.log(`${settings.theme} ${settings.fontSize}`);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeType the edge, then fix what it finds

        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.

        Starter codePop out in the code editor (opens in a new tab)TypeScript
        type Product = { id: number; name: string; cents: number }; const reply = '[{ "id": 1, "name": "Tea", "cents": 300 }, { "id": 2, "name": "Cake", "cents": 450 }]';const products = JSON.parse(reply);const total = products.reduce((sum, item) => sum + item.cent, 0);console.log(total);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeClear a finished to-do

          This file has strictNullChecks on. Which line does TypeScript mark, and why? Then fix it.

          Starter codePop out in the code editor (opens in a new tab)TypeScript
          const stock = new Map([["tea", 5]]); function stockLabel(product: string) {  // @ts-expect-error TODO(strict): a product that isn't in stock  return (stock.get(product) ?? 0).toString();} console.log(stockLabel("jam"));

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeFinish a file for strict

            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?

            Starter codePop out in the code editor (opens in a new tab)TypeScript
            function lineTotal(item, quantity) {  return item.cents * quantity;} console.log(lineTotal({ name: "Tea", cents: 300 }, 2));

            Answer, then press Check. Spacing and letter case don’t matter.

              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.

              The Migrating to TypeScript quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does "allowJs": true do at the start of a migration?

                Choose an answer to see the explanation.

              2. 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.

              3. 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)TypeScript
                const order = {};
                order.total = 750;
                console.log(order.total);

                Choose an answer to see the explanation.

              4. Question 4 of 7Which file of the shop should you convert first?

                Choose an answer to see the explanation.

              5. 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)TypeScript
                const 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.

              6. Question 6 of 7Why type the edges first, like the reply from the server?

                Choose an answer to see the explanation.

              7. 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, and noEmit, plus a script that runs tsc.
              • 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 .ts file, {} 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.

              CompleteFrontend Clear concepts. Working examples.