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

💡 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)); // 30

2. 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));      // 35

3. 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
//    anonymous

Common Errors (and the fix)

Pro Tips

📚 Keep Learning

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

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

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.

Related lessons