Migrating JavaScript to TypeScript
Reviewed & published by Brayan K
Migrating to TypeScript is an incremental process — you enable allowJs, add JSDoc and // @ts-check, rename files .js → .ts one at a time, replace any with real types, and turn on strictness gradually rather than all at once.
Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Let .js and .ts coexist with allowJs / checkJs
- Type plain .js files using JSDoc + // @ts-check
- Rename files .js -> .ts one leaf at a time
- Replace implicit any with real interfaces and types
- Enable strict flags gradually (noImplicitAny, strictNullChecks)
- Follow a safe, reviewable migration order
💡 Real-World Analogy
Migrating to TypeScript is like renovating a house while still living in it. You don't demolish everything and move out — you do one room at a time, keeping the place habitable throughout. allowJs lets the old and new rooms share a roof; // @ts-check is putting up safety tape in a room before you fully redo it; flipping strict flags one by one is tightening the building code gradually instead of failing every inspection at once.
1. Start Without Renaming: // @ts-check + JSDoc
You can get type-checking on a plain .js file before renaming anything. Add // @ts-check at the top (or set allowJs + checkJs in tsconfig), then describe types with JSDoc comments. The editor checks them; the code still runs as ordinary JavaScript because JSDoc is just comments.
// In tsconfig.json, opt JS files into checking gradually:
{
"compilerOptions": {
"allowJs": true, // let .js files into the program
"checkJs": true, // type-check them too (or use // @ts-check per file)
"outDir": "dist"
}
}
// Per-file control, no config needed:
// // @ts-check at the top of a .js file turns ON checking
// // @ts-nocheck turns it OFF for a stubborn file
// // @ts-expect-error silences the NEXT line (and warns if it's not an error)
// JSDoc gives you real types in plain .js:
/** @type {{ id: number, name: string }} */
const user = { id: 1, name: "Ada" };// MIGRATION step 1: add // @ts-check + JSDoc types to a PLAIN .js file. No rename,
// no build step — the editor type-checks the JSDoc. The code still runs as normal JS.
// At runtime JSDoc is just comments, so behaviour is identical before and after.
// @ts-check
/**
* @param {number} a
* @param {number} b
* @returns {number}
*/
function add(a, b) {
return a + b;
}
console.log(add(2, 3)); // 5
// With // @ts-check on, calling add("2", 3) would be FLAGGED by the editor —
// but at RUNTIME JS still coerces, so we only pass numbers here:
console.log(add(10, 20)); // 302. Rename to .ts & Replace any
Next, rename files .js → .ts from the leaves of your dependency tree upward, so each converted file only imports already-typed code. As you go, replace the implicit any on parameters and data with real interfaces and types — and prefer unknown over any for genuinely uncertain values.
// After renaming order.js -> order.ts, give the data a real shape:
interface Item { price: number; qty: number }
interface Order { items: Item[] }
function total(order: Order): number {
return order.items.reduce((sum, it) => sum + it.price * it.qty, 0);
}
// Strategy for 'any': type it explicitly while you migrate, then tighten:
// 1. "noImplicitAny": false -> lets the build pass at first
// 2. annotate the obvious cases, replacing 'any' with real types
// 3. flip "noImplicitAny": true -> the compiler finds the rest
// 'unknown' is a safer placeholder than 'any' — it forces a check before use.// MIGRATION step 2: rename .js -> .ts and replace implicit 'any' with real shapes.
// The RUNTIME logic is identical — typing 'any' away is a compile-time improvement.
// "Before" — loose, any-typed access (works, but no safety):
function totalLoose(order) {
return order.items.reduce((sum, it) => sum + it.price * it.qty, 0);
}
// "After" — same logic; in .ts you'd add the interfaces shown below:
// interface Item { price: number; qty: number }
// interface Order { items: Item[] }
function total(order) {
return order.items.reduce((sum, it) => sum + it.price * it.qty, 0);
}
const order = { items: [{ price: 10, qty: 2 }, { price: 5, qty: 3 }] };
console.log(totalLoose(order)); // 35
console.log(total(order)); // 353. Turn On Strictness Gradually
Resist setting "strict": true on day one — it can surface thousands of errors at once. Enable flags one at a time: noImplicitAny, then strictNullChecks (usually the biggest payoff), then the rest, finishing with full strict. strictNullChecks in particular forces you to add the null guards that prevent real runtime crashes.
// Turn on strictness ONE flag at a time so each PR stays reviewable:
{
"compilerOptions": {
"strict": false, // don't flip everything at once
"noImplicitAny": true, // 1) ban silent any
"strictNullChecks": true, // 2) force null/undefined handling
"strictFunctionTypes": true // 3) stricter function compatibility
// ...finally "strict": true once the above are clean
}
}
interface User { name: string }
function greet(user: User | null): string {
if (!user) return "Hi guest"; // strictNullChecks REQUIRES this guard
return "Hi " + user.name.toUpperCase();
}// MIGRATION step 3: enable strict checks gradually. A big win is strictNullChecks,
// which forces you to handle null/undefined. The runtime FIX is a null guard.
// Before strict: this would CRASH on a missing user (TypeError at runtime).
function greetUnsafe(user) {
return "Hi " + user.name.toUpperCase();
}
// After turning on strictNullChecks, TS forces a guard. The safe runtime version:
function greet(user) {
if (!user || !user.name) return "Hi guest"; // handle the null/undefined case
return "Hi " + user.name.toUpperCase();
}
console.log(greet({ name: "Ada" })); // Hi ADA
console.log(greet(null)); // Hi guest
console.log(greet({})); // Hi guest🎯 Your Turn
Add the null guard that strictNullChecks would demand, so firstName(null) returns "anonymous" instead of crashing. Fill in the two blanks marked ___, then run it.
// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// strictNullChecks would flag user.name when user can be null. Add the guard so
// firstName(null) safely returns "anonymous".
function firstName(user) {
if (!___) return "anonymous"; // 👉 guard against a falsy 'user'
return user.name.split(" ")[___]; // 👉 the FIRST word -> index 0
}
console.log(firstName({ name: "Ada Lovelace" })); // Ada
console.log(firstName(null)); // anonymous
// ✅ Expected output:
// Ada
// anonymousCommon Errors (and the fix)
- ❌ "Parameter 'x' implicitly has an 'any' type" — noImplicitAny is on and a param is untyped. ✅ Annotate it, or temporarily set noImplicitAny: false while migrating.
- ❌ "Object is possibly 'null'" — strictNullChecks flagged unguarded access. ✅ Add a guard (if (!x) return ...) or use optional chaining.
- ❌ "Could not find a declaration file for module..." after renaming. ✅ npm i -D @types/..., or add a declare module stub.
- ❌ Flipping "strict": true floods you with errors — too much at once. ✅ Enable one strict sub-flag per PR instead.
Pro Tips
- 💡 Convert from the leaves inward — files with the fewest dependencies first, so each .ts imports already-typed code.
- 💡 Use // @ts-expect-error (not // @ts-ignore) for known issues — it warns you when the error is finally fixed.
- 💡 Lean on JSDoc to add types without a build step; it's a genuine on-ramp, not a toy.
- 💡 Track progress by counting remaining anys or un-migrated files — visible momentum keeps a migration alive.
📚 Keep Learning
- 📖 TypeScript Handbook — Migrating from JavaScript
- 📖 Handbook — JSDoc Supported Types
- 📖 tsconfig — strict and its sub-flags
Mini-Challenge: A Strict-Safe Config Parser
No blanks this time — just a brief and a starting outline. Migrate the parser to handle missing input safely, run it, and check your output against the example in the comments.
// 🎯 MINI-CHALLENGE: migrate a config parser to be strict-safe.
// Imagine porting this from JS with strictNullChecks now ON.
// 1. parseConfig(raw): if raw is missing OR raw.port is not a number,
// return safe defaults { port: 8080, host: "localhost" }.
// 2. Otherwise return { port: raw.port, host: raw.host || "localhost" }.
// 3. Log parseConfig({ port: 3000, host: "0.0.0.0" }),
// parseConfig({ port: 3000 }), and parseConfig(null).
//
// ✅ Expected output:
// { port: 3000, host: '0.0.0.0' }
// { port: 3000, host: 'localhost' }
// { port: 8080, host: 'localhost' }
// your code here🎉 Lesson Complete
- ✅ Migration is incremental — .js and .ts coexist via allowJs
- ✅ // @ts-check + JSDoc type a plain .js file with no rename or build step
- ✅ Rename .js → .ts from the leaves inward, replacing implicit any
- ✅ Prefer unknown over any; flip noImplicitAny on to find the rest
- ✅ Enable strict flags one at a time; strictNullChecks forces real null guards
Practice quiz
What is the recommended way to migrate JavaScript to TypeScript?
- Rewrite the entire codebase at once
- Delete all .js files first
- Migrate incrementally, file by file
- Avoid types entirely
Answer: Migrate incrementally, file by file. Migration is incremental: .js and .ts coexist and you convert files one at a time.
What does the allowJs compiler option do?
- Lets .js files be part of the TypeScript program
- Deletes JavaScript files
- Disables type checking
- Converts .js to .ts automatically
Answer: Lets .js files be part of the TypeScript program. allowJs lets JavaScript files join the program so they can import and be imported by .ts files.
How can you type a plain .js file without renaming it?
- You cannot type a .js file
- Use a .ts extension secretly
- Add a runtime type library
- Add // @ts-check and JSDoc comments
Answer: Add // @ts-check and JSDoc comments. // @ts-check plus JSDoc gives editor type-checking on a plain .js file, with no rename.
At runtime, what is JSDoc?
- Compiled type assertions
- Just comments, so behaviour is unchanged
- A separate runtime library
- Replaced by real types
Answer: Just comments, so behaviour is unchanged. JSDoc is comments; the code runs identically before and after adding it.
Which is a safer placeholder than any during migration?
- unknown
- object
- void
- never
Answer: unknown. unknown forces you to narrow before use, keeping safety; any throws all checking away.
What is the suggested order for enabling strict flags?
- Set strict: true on day one
- Only ever enable strictNullChecks
- One flag at a time, e.g. noImplicitAny then strictNullChecks
- Enable them randomly
Answer: One flag at a time, e.g. noImplicitAny then strictNullChecks. Flip flags one at a time so each pull request stays small and reviewable.
What does strictNullChecks force you to do?
- Remove all null values
- Handle null and undefined explicitly
- Use any everywhere
- Disable optional properties
Answer: Handle null and undefined explicitly. strictNullChecks makes you add guards for null/undefined, preventing real runtime crashes.
In what order should you rename files from .js to .ts?
- Top-level entry points first
- Alphabetically
- Largest files first
- From the leaves of the dependency tree inward
Answer: From the leaves of the dependency tree inward. Convert leaf files first so each new .ts only imports already-typed code.
Which comment is preferred over // @ts-ignore for a known issue?
- // @ts-skip
- // @ts-expect-error
- // @ts-off
- // @no-check
Answer: // @ts-expect-error. // @ts-expect-error warns you when the error is finally fixed, unlike // @ts-ignore.
What does noImplicitAny do once enabled?
- Bans all type annotations
- Converts any to unknown
- Flags parameters and values that silently became any
- Disables JSDoc
Answer: Flags parameters and values that silently became any. noImplicitAny surfaces every spot still missing a type by banning silent any.
Continue this course
- Previous: Decorators
- Next: Checkpoint: Applied TypeScript — Build a small typed module combining several features — then a quiz
- Quick reference: TypeScript cheat sheet
Frequently asked questions
Do I have to rewrite everything at once to adopt TypeScript?
No — migration is incremental. Set allowJs so .js and .ts coexist in one project, then convert files one at a time. You can even type plain .js files in place using // @ts-check and JSDoc before renaming anything. A typical order is: enable allowJs/checkJs, add JSDoc types, rename leaf files to .ts, then tighten strictness.
What do allowJs and checkJs do?
allowJs lets JavaScript files be part of your TypeScript program so they can import and be imported by .ts files. checkJs additionally type-checks those .js files (using inferred types and any JSDoc you've added). You can scope checkJs per file with // @ts-check instead of turning it on globally, which is great for gradual adoption.
How should I deal with 'any' during migration?
Treat any as temporary scaffolding. Start with noImplicitAny off so the build passes, annotate the obvious shapes, then flip noImplicitAny on to surface the rest. Prefer 'unknown' over 'any' for genuinely uncertain values, because unknown forces you to narrow before use — keeping the safety TypeScript is meant to provide.
What order should I enable strict flags in?
Flip them one at a time rather than setting 'strict': true on day one. A common sequence is noImplicitAny, then strictNullChecks (usually the biggest payoff and the most work), then strictFunctionTypes and the rest, and finally 'strict': true once everything is clean. One flag per pull request keeps each change small and reviewable.