On this page

Migrating JavaScript to TypeScript

12 min read TextCh. 5 — TypeScript in Practice

Why migrate?

JavaScript projects can grow to hundreds of thousands of lines without a single type annotation. Refactoring becomes risky, API contracts drift from their implementations, and onboarding new developers takes longer because the codebase has no machine-readable documentation.

TypeScript solves these problems at compile time — before the code ever runs. The migration does not need to happen all at once. TypeScript is designed for incremental adoption, and you can reach full type safety in stages without breaking the application between each phase.

Two migration strategies

Full migration (greenfield or small projects)

Rename every .js file to .ts, fix all type errors at once, then enable strict mode. Best for small codebases (under ~5,000 lines) or when you have a complete test suite that catches regressions.

Incremental migration (large codebases)

Enable TypeScript alongside the existing JavaScript, add types file by file, and progressively tighten the compiler settings. This is the strategy most teams use in practice.

Phase 1: Set up the compiler without breaking anything

Start with the most permissive configuration that still gives you some value:

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "noEmit": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler"
  }
}

allowJs: true lets TypeScript process .js files. checkJs: false means no errors are reported for JavaScript yet. noEmit: true means TypeScript only type-checks — your existing build tool still handles compilation. This setup has zero impact on your output.

Phase 2: Enable JSDoc type checking

Turn on checkJs: true to make TypeScript validate JavaScript files using JSDoc comments. This lets you add types incrementally without renaming files:

// user-service.js — still a .js file, but now type-checked via JSDoc
/**
 * @param {number} id
 * @returns {Promise<{id: number, name: string, email: string}>}
 */
async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

This phase is particularly valuable for shared utilities and service layers that many files depend on.

Phase 3: Rename files to .ts

Pick a module — ideally a leaf in the dependency graph (no local imports) — and rename it from .js to .ts. Fix the type errors that appear. Once it compiles cleanly, the files that import it gain concrete types for free.

A typical migration order:

  1. Constants and enums
  2. Type/interface definitions
  3. Pure utility functions
  4. Service classes and data-access layers
  5. Component/controller files
  6. Entry points

Fixing common migration errors

Implicit `any`

When TypeScript cannot infer a type, it defaults to any. With noImplicitAny: true this becomes an error:

// Error: parameter 'data' implicitly has an 'any' type
function transform(data) { ... }

// Fix: add an explicit type or use unknown
function transform(data: unknown) { ... }

Object literal access on dynamic keys

const cache: Record<string, string> = {};

// Error: element implicitly has 'any' type because 'string' can't index 'typeof config'
function get(config: { host: string }, key: string) {
  return config[key]; // dynamic access
}

// Fix: use proper typing or an index signature
function get(config: Record<string, string>, key: string): string | undefined {
  return config[key];
}

Third-party libraries without types

When you import a library that has no .d.ts file and no @types package:

// Error: could not find a declaration file for module 'legacy-chart'
import LegacyChart from "legacy-chart";

Options in order of preference:

  1. Install community types: npm install --save-dev @types/legacy-chart
  2. Write a minimal ambient declaration in src/types/legacy-chart.d.ts
  3. Temporarily add "skipLibCheck": true and suppress the specific import
// src/types/legacy-chart.d.ts
declare module "legacy-chart" {
  export interface ChartOptions {
    type: "bar" | "line" | "pie";
    data: number[];
    labels: string[];
  }
  export default class Chart {
    constructor(canvas: HTMLCanvasElement, options: ChartOptions);
    render(): void;
    destroy(): void;
  }
}

@ts-expect-error vs @ts-ignore

Both suppress the type error on the next line, but they behave differently:

// @ts-expect-error — TypeScript WILL error if the next line has NO type error
// (useful: it alerts you when the suppression can be removed)
// @ts-expect-error: TODO fix legacy API call
const result = legacyApi.call(null, badArg);

// @ts-ignore — silently suppresses forever
// Use only for generated code, never for your own
// @ts-ignore
const result2 = generatedCode();

Always prefer @ts-expect-error with a comment explaining why the suppression is needed. When the underlying issue is fixed, TypeScript will tell you to remove the comment.

Enabling strict mode progressively

Enable strict checks one by one rather than all at once to avoid an overwhelming wall of errors:

{
  "compilerOptions": {
    "noImplicitAny": true,        // Start here
    "strictNullChecks": true,     // Then this
    "strictFunctionTypes": true,
    "strictPropertyInitialization": true,
    "noImplicitReturns": true,
    "noUncheckedIndexedAccess": true
    // Finally: "strict": true  (enables all of the above and more)
  }
}

Each flag you enable is a milestone. Celebrate them — they represent real bugs caught before reaching production.

Tracking migration progress

A simple way to measure progress is to count the number of remaining any occurrences and suppression comments:

# Count explicit and implicit any usage
grep -r "any" src --include="*.ts" | grep -v "\.d\.ts" | wc -l

# Count suppressions
grep -rn "@ts-ignore\|@ts-expect-error" src --include="*.ts" | wc -l

Set a target (e.g., zero suppression comments, zero any occurrences) and track it in your CI pipeline using --noImplicitAny and a linting rule like @typescript-eslint/no-explicit-any.


Practice

  1. Phase 1 setup: Create a tsconfig.json with allowJs: true, checkJs: false, and noEmit: true. Add a sample utils.js file with two untyped helper functions. Confirm the project compiles without errors.
  2. JSDoc types: Enable checkJs: true. Add JSDoc type annotations to your utils.js helper functions. Fix any type errors TypeScript reports.
  3. Rename to .ts: Rename utils.js to utils.ts. Replace the JSDoc annotations with TypeScript type annotations. Enable noImplicitAny: true and fix the remaining errors.
  4. Ambient declarations: Install any small JavaScript library that lacks types. Write a minimal .d.ts ambient module declaration for it and use it from a .ts file.

Final lesson: put everything together in the TypeScript task management system project.

Migrate one file at a time, starting from the leaves
Start with the files that have no local imports (utility functions, constants, pure helpers). These have no upstream dependencies to untangle. Once typed, they provide concrete types to the files that import them, propagating safety upward through the dependency graph.
Prefer @ts-expect-error over @ts-ignore
`@ts-expect-error` causes a compile error if the suppressed line stops having an error — which means TypeScript will alert you when you can safely remove the suppression. `@ts-ignore` is silent forever. Use `@ts-ignore` only for generated code or truly unavoidable third-party issues.
DefinitelyTyped covers most popular packages
Before writing your own `.d.ts` declarations for a JavaScript library, check https://www.npmjs.com/~types or run `npm install --save-dev @types/package-name`. Over 8,000 packages have community-maintained type definitions in DefinitelyTyped.
json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "outDir": "./dist",
    "rootDir": "./src",

    // Phase 1 — allow JS files in the compilation
    "allowJs": true,

    // Phase 2 — type-check JS files using JSDoc
    "checkJs": true,

    // Phase 3 — fail the build on any type error
    "strict": true,
    "noImplicitAny": true,

    // Helpful for large migrations
    "skipLibCheck": true,
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
typescript
// --- BEFORE: typical untyped JavaScript pattern ---
function processOrder(order) {
  const total = order.items.reduce((sum, item) => sum + item.price * item.qty, 0);
  return { ...order, total, processedAt: new Date() };
}

// --- AFTER: incrementally typed ---

// Step 1: add interfaces
interface OrderItem {
  id: string;
  name: string;
  price: number;
  qty: number;
}

interface Order {
  id: string;
  customerId: string;
  items: OrderItem[];
}

interface ProcessedOrder extends Order {
  total: number;
  processedAt: Date;
}

// Step 2: type the function
function processOrder(order: Order): ProcessedOrder {
  const total = order.items.reduce(
    (sum, item) => sum + item.price * item.qty,
    0
  );
  return { ...order, total, processedAt: new Date() };
}

// @ts-expect-error — use when a specific line is known to have an error
// and you plan to fix it in the next iteration
const broken = processOrder(null);

// @ts-ignore — suppresses the NEXT line silently (prefer @ts-expect-error)
// @ts-ignore
const alsobroken = processOrder(undefined);