Migrating JavaScript to TypeScript

TypeScript · Lesson 27 of 28 7 min read Updated

Most developers do not start with a fresh TypeScript project. They join a team with thousands of lines of JavaScript that already work. Rewriting everything at once is risky and slow. The good news is that TypeScript was designed for gradual adoption: JavaScript and TypeScript files can live in the same project, and you can add types one file at a time while the app keeps running.

In this lesson you will follow a safe, step-by-step plan: add TypeScript to a JavaScript project, check JavaScript files with JSDoc comments, convert files to .ts, fix the errors you will typically meet, and tighten the settings over time.

Step 1: add TypeScript without changing any code

Install TypeScript and create a tsconfig.json that accepts JavaScript files but does not complain about them yet:

JSON
{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "types": ["node"],
    "allowJs": true,
    "checkJs": false,
    "noEmit": true,
    "strict": false,
    "skipLibCheck": true
  },
  "include": ["src"]
}
  • allowJs lets .js files be part of the project, so .ts files can import from them and vice versa.
  • checkJs: false means JavaScript files are not type-checked yet.
  • noEmit keeps your existing build process. TypeScript is only a checker for now.
  • strict: false is a temporary starting point for an old code base; you will turn it on later. (For a small project, try strict: true straight away.)

Run npx tsc. It should finish with few or no errors. Add "check": "tsc" to your npm scripts and to your CI pipeline now, so the number of errors can only go down from here.

Step 2: check JavaScript files with JSDoc

Before renaming anything, you can get type checking in a .js file by adding // @ts-check at the top. TypeScript then reads types from JSDoc comments, a standard comment format many JavaScript projects already use:

JavaScript
// @ts-check

/**
 * @typedef {{ name: string, fee: number, paid: number }} Student
 */

/**
 * @param {Student} student
 * @returns {number}
 */
export function balance(student) {
  return student.fee - student.paid;
}

balance({ name: "Kabir", fee: 12000 });
console.log(balance({ name: "Meera", fee: 12000, paid: "8000" }));
Output
fees.js(15,9): error TS2741: Property 'paid' is missing in type '{ name: string; fee: number; }' but required in type 'Student'.
fees.js(16,50): error TS2322: Type 'string' is not assignable to type 'number'.

The file is still plain JavaScript, so nothing about your build changes, yet you get the same checks as in a .ts file. Some projects stay with JSDoc permanently because they want to ship JavaScript without a build step. Note that TypeScript 7 supports a slightly smaller set of JSDoc patterns than older versions: for example, @enum is no longer treated specially, and a value can no longer be used as a type without typeof.

Step 3: rename files, starting at the leaves

Now convert files from .js to .ts, one or a few at a time. Start with leaf modules: files that do not import other project files, such as utility functions and constants. Their types then flow into every file that imports them. Work upwards towards the files that import many others, such as routes and pages.

After renaming, you will meet the same few errors again and again:

TypeScript
export function summary(students, month) {
  const due = students.filter((s) => s.fee > s.paid);
  return `${month}: ${due.length} students have dues`;
}

export function findStudent(students: { name: string }[], name: string) {
  return students.find((s) => s.name === name).name;
}
Output
report.ts(1,25): error TS7006: Parameter 'students' implicitly has an 'any' type.
report.ts(1,35): error TS7006: Parameter 'month' implicitly has an 'any' type.
report.ts(2,32): error TS7006: Parameter 's' implicitly has an 'any' type.
report.ts(7,10): error TS2532: Object is possibly 'undefined'.

These two errors cover most of a migration. Implicit any needs a type annotation on the parameters (the s error disappears once students has a type). "Possibly undefined" means a real edge case: find returns undefined when nothing matches, and the old JavaScript would have crashed with "Cannot read properties of undefined". Here is the fixed version, which you can run:

TypeScriptRun it yourself
type Student = { name: string; fee: number; paid: number };

export function summary(students: Student[], month: string): string {
  const due = students.filter((s) => s.fee > s.paid);
  return `${month}: ${due.length} students have dues`;
}

export function findStudent(students: Student[], name: string): Student | undefined {
  return students.find((s) => s.name === name);
}

const batch: Student[] = [
  { name: "Aarav", fee: 12000, paid: 12000 },
  { name: "Meera", fee: 12000, paid: 8000 },
  { name: "Rohan", fee: 9000, paid: 4500 },
];

console.log(summary(batch, "October"));
console.log(findStudent(batch, "Meera")?.paid ?? "not found");
console.log(findStudent(batch, "Kabir")?.paid ?? "not found");
Output
October: 2 students have dues
8000
not found

Other common fixes during a migration:

  • Objects built step by step. const order = {}; order.id = 1; is an error because {} has no id. Build the object in one go, or give it a type first: const order: Partial<Order> = {}.
  • Missing types for packages. Install @types/<name> or write a small declare module file (see Declaration Files & DefinitelyTyped).
  • Dynamic property access. config[key] with a string key fails on a typed object; type the key as keyof Config.
  • Enums and namespaces. If you are adding types to old code, prefer unions and as const objects, as in Enums & Erasable Syntax.

Step 4: tighten the settings

Once most files are .ts, make the compiler stricter, one option at a time, fixing the errors after each step:

  1. Turn on noImplicitAny, then strictNullChecks, then the full "strict": true.
  2. Turn on checkJs for any JavaScript files that remain, or finish converting them and remove allowJs.
  3. Add noUncheckedIndexedAccess and the other extra checks from Strict tsconfig Options.

When one step produces too many errors to fix at once, mark the hard cases and move on:

TypeScript
// @ts-expect-error: legacy payment SDK returns a string amount; fix in ticket PAY-142
const amount: number = legacySdk.lastAmount();

// @ts-expect-error silences the error on the next line, but becomes an error itself once the problem is fixed, so these markers cannot pile up silently. Always add a reason. Avoid // @ts-ignore and // @ts-nocheck, which hide errors forever.

Common mistakes

  • Converting everything at once. Big-bang migrations stall. Convert a few files per change and keep the app working.
  • Using any to finish faster. Each any is a hole in the type system. Prefer unknown, a real type, or a marked @ts-expect-error.
  • Fixing errors by changing types, not code. "Object is possibly undefined" is often a real bug. Handle the missing case.
  • Starting at the top of the import tree. Pages and routes depend on everything; start with the utilities they import.
  • Leaving strict off forever. Plan when each strict option will be turned on.

Key points

  • TypeScript supports gradual migration: .js and .ts files can live together with allowJs.
  • // @ts-check plus JSDoc comments type-check a JavaScript file without renaming it.
  • Rename files to .ts starting with leaf modules, and fix implicit any and possibly undefined errors as you go.
  • Tighten settings step by step: noImplicitAny, strictNullChecks, full strict, then extra checks.
  • Use // @ts-expect-error with a reason for temporary exceptions, never @ts-ignore.

Exercise

Convert this JavaScript function from a shop in Lucknow to TypeScript with full types, and fix the hidden bug the compiler will point out: export function priceOf(products, id) { return products.find((p) => p.id === id).price; }. Products have an id (string), a name and a price. The function should return null when the product does not exist. Test it with one existing and one missing id.

Show solution
TypeScript
type Product = { id: string; name: string; price: number };

export function priceOf(products: readonly Product[], id: string): number | null {
  const product = products.find((p) => p.id === id);
  return product ? product.price : null;
}

const menu: Product[] = [
  { id: "kc", name: "Kulfi cone", price: 40 },
  { id: "ch", name: "Chaat plate", price: 70 },
];

console.log(priceOf(menu, "ch")); // 70
console.log(priceOf(menu, "pb")); // null

The original code crashed with "Cannot read properties of undefined" for a missing id. With types, find returns Product | undefined, so TypeScript forces you to handle the missing case.

Quick Quiz

1. Which option lets .ts and .js files exist in the same TypeScript project?

2. How can you type-check a single .js file without renaming it?

3. Which files should you convert first?

4. Why is // @ts-expect-error better than // @ts-ignore?

Learn TypeScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call