DevCerts logo DevCerts

Migrating JavaScript to TypeScript Without Stopping Delivery

A practical migration plan for moving a JavaScript codebase to TypeScript while the team keeps shipping: start with allowJs, introduce checkJs selectively, enable strict mode by module, type critical paths first, and make CI enforce progress instead of blocking everything.

JavaScript TypeScript
Migrating JavaScript to TypeScript Without Stopping Delivery

Migrating a JavaScript project to TypeScript should not require a feature freeze. In most production teams, stopping delivery for a large rewrite is not realistic, and it often creates more risk than it removes. The safer approach is to treat TypeScript adoption as an incremental engineering program: preserve runtime behavior, add type coverage where it reduces real defects, and tighten compiler rules only where the team can absorb the cost.

The central idea is simple: do not migrate files just to change extensions. Migrate boundaries, contracts, and failure-prone areas first. allowJs, checkJs, module-level strict configs, typed critical zones, and CI checks let a team improve type safety without turning the whole codebase into a blocking refactor.

What teams usually get wrong

The most common mistake is treating TypeScript migration as a binary switch: JavaScript on Monday, TypeScript on Friday. That model fails because real projects contain legacy modules, dynamic data, third-party integrations, implicit contracts, tests with mocks, and runtime assumptions that were never written down.

Another mistake is renaming too many files too early. A .ts extension does not automatically make code safer. If the file still uses any, passes unvalidated API responses around, and hides domain rules inside loosely shaped objects, the migration has mostly changed syntax.

A useful migration plan should protect delivery by separating four concerns:

  • Can JavaScript and TypeScript coexist?

  • Which JavaScript files should be type-checked before conversion?

  • Which modules should become strict first?

  • Which parts of the system deserve explicit types before everything else?

That order matters. It turns migration from a rewrite into a sequence of controlled compiler gates.

Phase 1: Make JavaScript and TypeScript coexist

The first milestone is not strict typing. It is compiler integration without disrupting the existing build. allowJs lets TypeScript include JavaScript files in the project, while noEmit is often useful when another tool already handles bundling or transpilation.

A conservative starting config can look like this:

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "noEmit": true,
    "incremental": true,
    "moduleResolution": "bundler",
    "target": "ES2022",
    "module": "ESNext",
    "strict": false,
    "skipLibCheck": true
  },
  "include": ["src"]
}

This does not make the project “typed” in a meaningful sense yet. It gives the team a compiler view of the codebase and makes it possible to introduce .ts and .tsx files without isolating them from the rest of the application.

At this point, avoid aggressive cleanup. The goal is to keep the application building, keep tests running, and make TypeScript part of the normal toolchain. Any migration plan that breaks local development on day one will lose trust quickly.

Phase 2: Use checkJs where conversion is not worth it yet

Not every JavaScript file needs to become TypeScript immediately. Some files are stable, small, or scheduled for replacement. Others are risky to rename because they interact with build tooling, test fixtures, or older module systems.

For those files, checkJs can provide useful compiler feedback without changing file extensions. It can be enabled globally later, but a safer path is to start locally with // @ts-check and JSDoc annotations.

// @ts-check

/**
 * @typedef {Object} Price
 * @property {number} amount
 * @property {"USD" | "EUR" | "GBP"} currency
 */

/**
 * @param {Price} price
 * @param {number} quantity
 * @returns {number}
 */
export function calculateSubtotal(price, quantity) {
  return price.amount * quantity;
}

This is not a substitute for TypeScript in complex modules, but it is useful for leaf utilities, shared helpers, and old JavaScript files where the team wants type feedback before deciding whether conversion is worth the churn.

The practical rule is: use checkJs to reduce blind spots, not to avoid TypeScript indefinitely.

Migration controls compared

A good migration uses several controls at once. Each one has a different operational role.

Control

Applies to

Runtime impact

Migration cost

Useful for

Main constraint

allowJs

JS and TS coexistence

None

Low

Starting the migration without rewriting files

Does not type-check JS by itself

checkJs

JavaScript files

None

Low to Medium

Finding errors in existing JS before conversion

JSDoc can become noisy in complex modules

File rename to .ts

Individual files

None if build output is equivalent

Medium

Modules with stable imports and clear contracts

May expose many implicit assumptions

Module-level strict config

Selected folders

None

Medium

Gradual hardening by domain or package

Requires disciplined CI setup

Typed boundary layer

API, queues, events, config

None to Low, depending on validation

Medium

Reducing production defects from malformed data

Needs ownership of data contracts

The table highlights a key point: TypeScript migration is not one action. It is a set of pressure valves. Use the light controls early, then apply stricter controls where the return is highest.

Phase 3: Enable strict mode by module, not by wishful thinking

Turning on strict for a large existing project can produce a wall of compiler errors. Many are useful. Many are also impossible to fix without interrupting roadmap work.

A more practical model is to keep the base config permissive and introduce strict configs for selected modules. For example, a checkout flow, authentication module, billing adapter, or shared SDK can be checked with stricter rules before the rest of the codebase is ready.

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "strict": true,
    "noImplicitReturns": true,
    "noUncheckedIndexedAccess": true
  },
  "include": ["src/checkout/**/*"]
}

This lets the team create a measurable migration path:

  1. Pick one module with clear ownership.

  2. Add a strict TypeScript config for that module.

  3. Fix compiler errors inside the boundary.

  4. Add the strict config to CI.

  5. Repeat with the next module.

The best migration signal is not how many files have .ts extensions. It is how many important modules are protected by compiler rules the team cannot silently bypass.

Strict mode works best when it is attached to ownership. A team responsible for payments can harden the payments module. A platform team can harden shared libraries. A frontend team can harden state management and API client code. This reduces coordination cost and avoids turning migration into a single cross-company bottleneck.

Phase 4: Type the critical zones first

In most applications, defects are not evenly distributed. TypeScript brings the most value where data crosses boundaries or where incorrect assumptions are expensive.

Prioritize these areas:

  • API request and response objects

  • Authentication and authorization logic

  • Payment, billing, and subscription flows

  • Queue payloads and event messages

  • Configuration and environment variables

  • Shared domain models used across many modules

  • State transitions in complex UI flows

  • Public package or SDK interfaces

For example, an API client that returns untyped data spreads uncertainty across the application:

// Weak migration: the caller still has to guess the shape.
export async function getCurrentUser(): Promise<any> {
  const response = await fetch("/api/me");
  return response.json();
}

A better version makes the boundary explicit:

type UserRole = "admin" | "editor" | "viewer";

interface CurrentUser {
  id: string;
  email: string;
  role: UserRole;
}

export async function getCurrentUser(): Promise<CurrentUser> {
  const response = await fetch("/api/me");

  if (!response.ok) {
    throw new Error("Failed to load current user");
  }

  const data = await response.json();

  return {
    id: String(data.id),
    email: String(data.email),
    role: data.role
  };
}

This example is intentionally small. In production, runtime validation may also be needed, especially when data comes from external systems. TypeScript describes expected shapes at compile time. It does not prove that a JSON payload is valid at runtime.

That distinction is important. TypeScript reduces many classes of mistakes inside the codebase, but it does not remove the need for validation at trust boundaries.

Phase 5: Make CI enforce progress, not perfection

A migration without CI usually drifts. Developers rename a few files, suppress errors, and move on. The project looks more typed, but the compiler does not become a reliable guardrail.

CI should enforce two things:

  • The current baseline must not get worse.

  • Strict modules must remain strict.

A minimal CI setup can run the general compiler check and one or more strict module checks:

name: type-check

on:
  pull_request:
  push:
    branches: [main]

jobs:
  types:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run typecheck
      - run: npm run typecheck:checkout

Package scripts can keep those checks explicit:

{
  "scripts": {
    "typecheck": "tsc -p tsconfig.json",
    "typecheck:checkout": "tsc -p tsconfig.checkout.strict.json"
  }
}

Do not make CI depend on a vague target like “finish TypeScript migration.” Make it enforce concrete rules:

  • New TypeScript files must pass the compiler.

  • Selected modules must pass strict checks.

  • New public APIs must have typed inputs and outputs.

  • Suppressions such as @ts-ignore must be reviewed.

  • Broad any usage should be limited in critical zones.

This creates a migration that gets stronger over time without blocking every unrelated feature.

Managing any, unknown, and suppressions

any is not always avoidable during migration, but it must be treated as technical debt with location and reason. The dangerous pattern is not a single any. It is an any that crosses module boundaries and becomes part of the application contract.

Prefer unknown when the value is genuinely not trusted yet:

function readFeatureFlag(value: unknown): boolean {
  if (typeof value === "boolean") {
    return value;
  }

  if (value === "true") {
    return true;
  }

  return false;
}

This forces narrowing before use. It is more work than any, but it documents uncertainty and prevents accidental property access.

For suppressions, use a narrow policy. @ts-expect-error is usually safer than @ts-ignore because it should become visible when the error no longer exists. Even then, suppressions should explain the reason, especially near public interfaces or business-critical code.

A practical migration sequence

A delivery-safe TypeScript migration can follow this order:

  1. Add TypeScript tooling with allowJs.

  2. Keep the existing build path stable.

  3. Add checkJs to selected JavaScript files with clear value.

  4. Convert leaf utilities and shared types first.

  5. Type API clients, event payloads, config, and domain boundaries.

  6. Create strict configs for one owned module at a time.

  7. Add each strict config to CI.

  8. Track suppressions and reduce them during normal feature work.

  9. Avoid large renaming waves unless the module is already understood.

  10. Make typed contracts part of new feature acceptance criteria.

The sequence is intentionally incremental. It lets teams ship product work while moving risk out of the codebase in visible steps.

For engineers who work with TypeScript as a core production skill, the Senior TypeScript Developer certification is the most relevant DevCerts track to review after building this kind of migration discipline.


Conclusion

A successful JavaScript to TypeScript migration is not measured by how quickly the repository changes file extensions. It is measured by whether important contracts become explicit, whether risky modules get stricter, and whether CI prevents regression without freezing delivery.

Start with coexistence through allowJs. Use checkJs where it gives fast feedback. Apply strict by module, tied to ownership. Type the zones where bad assumptions cause production defects. Then let CI preserve every step forward.

That approach is slower than a rewrite on paper, but it is usually safer in real teams because it respects the actual constraints of production software: shipping, maintenance, testing, ownership, and risk.