Migrating JavaScript to TypeScript
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:
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"types": ["node"],
"allowJs": true,
"checkJs": false,
"noEmit": true,
"strict": false,
"skipLibCheck": true
},
"include": ["src"]
}allowJslets.jsfiles be part of the project, so.tsfiles can import from them and vice versa.checkJs: falsemeans JavaScript files are not type-checked yet.noEmitkeeps your existing build process. TypeScript is only a checker for now.strict: falseis a temporary starting point for an old code base; you will turn it on later. (For a small project, trystrict: truestraight 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:
// @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" }));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:
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;
}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:
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");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 noid. 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 smalldeclare modulefile (see Declaration Files & DefinitelyTyped). - Dynamic property access.
config[key]with astringkey fails on a typed object; type the key askeyof Config. - Enums and namespaces. If you are adding types to old code, prefer unions and
as constobjects, 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:
- Turn on
noImplicitAny, thenstrictNullChecks, then the full"strict": true. - Turn on
checkJsfor any JavaScript files that remain, or finish converting them and removeallowJs. - Add
noUncheckedIndexedAccessand 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:
// @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
anyto finish faster. Eachanyis a hole in the type system. Preferunknown, 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
strictoff forever. Plan when each strict option will be turned on.
Key points
- TypeScript supports gradual migration:
.jsand.tsfiles can live together withallowJs. // @ts-checkplus JSDoc comments type-check a JavaScript file without renaming it.- Rename files to
.tsstarting with leaf modules, and fix implicitanyand possiblyundefinederrors as you go. - Tighten settings step by step:
noImplicitAny,strictNullChecks, fullstrict, then extra checks. - Use
// @ts-expect-errorwith 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
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")); // nullThe 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?
allowJs includes JavaScript files in the program.
2. How can you type-check a single .js file without renaming it?
@ts-check turns on checking for that file, reading types from JSDoc comments.
3. Which files should you convert first?
Typed leaf modules give types to every file that imports them.
4. Why is // @ts-expect-error better than // @ts-ignore?
An unused @ts-expect-error is reported, which keeps temporary exceptions honest.
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
