How to Migrate a JavaScript Project to TypeScript

Type: Software Reference Confidence: 0.93 Sources: 9 Verified: 2026-02-23 Freshness: monthly

TL;DR

Constraints

Quick Reference

JavaScript PatternTypeScript EquivalentExample
function add(a, b)Typed parametersfunction add(a: number, b: number): number
const obj = {}Interface / type aliasconst obj: User = { name: 'Alice', age: 30 }
module.exports = fnESM exportexport default fn or export { fn }
require('pkg')ESM importimport pkg from 'pkg'
/** @param {string} name */JSDoc type annotation (bridge)Works in .js files with checkJs: true
// @ts-ignoreSuppress single lineUse // @ts-expect-error instead (errors if no issue exists)
// @ts-nocheckSuppress entire filePlace at top of file to skip type checking temporarily
.js extension.ts extensionRename file, fix type errors, commit
.jsx extension.tsx extensionRename, add prop types via interface Props {}
any (implicit)Explicit types or unknownunknown forces type narrowing; any disables checking
Object.keys(obj).forEach(...)Type-safe iteration(Object.keys(obj) as Array<keyof typeof obj>).forEach(...)
Dynamic property access obj[key]Index signatureRecord<string, unknown> or { [key: string]: Value }
callback(err, result)Typed callbacks / genericscallback: (err: Error | null, result: T) => void
Promise.all([...])Typed tuple inferenceconst [a, b] = await Promise.all([fetchA(), fetchB()]) auto-infers
Third-party library without typesInstall @types/ or declare modulenpm i -D @types/lodash or create types/lodash.d.ts

Decision Tree

START
├── Is this a greenfield project or existing JS codebase?
│   ├── GREENFIELD → Start with TypeScript from scratch: npm create vite@latest -- --template ts
│   └── EXISTING JS CODEBASE ↓
├── How large is the codebase?
│   ├── < 5,000 LOC → Manual migration: rename files, add types, enable strict
│   ├── 5,000-50,000 LOC → Incremental migration with allowJs: true (this guide)
│   └── > 50,000 LOC → Use AI-assisted tools (Cline + Gemini/Claude) or codemod scripts
├── Does the project use a bundler (Webpack, Vite, esbuild)?
│   ├── YES → Add ts-loader or @babel/preset-typescript to existing config
│   └── NO (plain Node.js) → Use tsc directly, or tsx for dev, or Node.js 22.18+ native TS
├── Does the project have JSDoc annotations?
│   ├── YES → Enable checkJs: true first — get type checking without renaming files
│   └── NO ↓
├── Which TypeScript version?
│   ├── TS 7.0 → strict is default; plan for full compliance from start
│   └── TS 5.x/6.x → Start with strict: false, tighten incrementally
└── DEFAULT → Set up tsconfig with allowJs, rename leaf modules first, work inward

Step-by-Step Guide

1. Install TypeScript and generate tsconfig.json

Install TypeScript as a dev dependency and create the configuration file. This does not change any runtime behavior. [src1]

npm install --save-dev typescript
npx tsc --init

Edit tsconfig.json for incremental migration:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "lib": ["ES2022"],
    "allowJs": true,
    "checkJs": false,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": false,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Note: TypeScript 5.9+ generates a more minimal tsc --init output with modern defaults ("module": "nodenext", "target": "esnext"). Adjust as needed for your Node.js version.

Verify: npx tsc --noEmit exits with code 0 (no errors on existing JS files).

2. Install type definitions for dependencies

Most popular npm packages have community-maintained type definitions in the @types namespace. [src2]

# Install types for common packages
npm install --save-dev @types/node @types/express @types/jest

# Auto-install missing @types packages
npx typesync

Verify: npx tsc --noEmit — no "Could not find a declaration file for module" errors for typed packages.

3. Rename leaf modules first (.js to .ts)

Start with files that have no local imports — utility functions, constants, config files. These have the smallest blast radius. [src3]

# Rename a file
mv src/utils/format.js src/utils/format.ts

# Fix type errors in the renamed file
npx tsc --noEmit

Verify: npx tsc --noEmit compiles. All tests pass: npm test.

4. Add type annotations to converted files

Add types to function parameters, return values, and variables where TypeScript cannot infer them. [src1, src2]

// BEFORE: JavaScript (src/utils/format.js)
function formatPrice(amount, currency) {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: currency,
  }).format(amount);
}

// AFTER: TypeScript (src/utils/format.ts)
function formatPrice(amount: number, currency: string): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency,
  }).format(amount);
}

Verify: npx tsc --noEmit — zero errors. Hover over function calls in your editor to confirm types are inferred.

5. Create declaration files for untyped dependencies

For third-party packages without @types, create a .d.ts declaration file. [src3, src4]

// types/untyped-package.d.ts
declare module 'untyped-package' {
  export function doSomething(input: string): Promise<Result>;
  export interface Result {
    success: boolean;
    data: unknown;
  }
}

Verify: npx tsc --noEmit — no "Could not find declaration" errors for the package.

6. Enable strict mode flags incrementally

Do not flip strict: true all at once. Enable individual flags one by one, fix errors for each, then move to the next. [src6]

Recommended order (easiest to hardest):

  1. noImplicitAny — forces explicit types on parameters
  2. strictNullChecks — distinguishes null/undefined from other types
  3. strictFunctionTypes — enforces correct function parameter variance
  4. strictBindCallApply — checks .bind(), .call(), .apply() arguments
  5. noImplicitThis — requires explicit this typing
  6. strictPropertyInitialization — enforces class property initialization
  7. alwaysStrict — emits "use strict" in output
  8. Replace all individual flags with "strict": true

Note: If targeting TypeScript 7.0, strict is the default. You may instead start with "strict": true and selectively disable individual flags while fixing errors.

Verify: After each flag, npx tsc --noEmit passes with zero errors.

7. Update build tooling and CI

Integrate TypeScript into your existing build pipeline and add type checking to CI. [src3]

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsc",
    "dev": "tsx watch src/index.ts",
    "ci": "npm run typecheck && npm run lint && npm test"
  }
}

For Node.js 22.18+ (native TypeScript, no build step for runtime):

# Run TypeScript directly (type annotations stripped at runtime)
node src/index.ts

# Still type-check separately
npx tsc --noEmit

Verify: npm run build succeeds. CI pipeline passes with type checking enabled.

Code Examples

Node.js/Express: Converting a route handler from JS to TS

// Input:  A JavaScript Express route with untyped request/response
// Output: Fully typed TypeScript Express route with error handling

import { Router, Request, Response, NextFunction } from 'express';

interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

interface CreateUserBody {
  name: string;
  email: string;
  role?: User['role'];
}

const router = Router();

// BEFORE (JavaScript):
// router.post('/users', async (req, res) => {
//   const user = await db.createUser(req.body);
//   res.json(user);
// });

// AFTER (TypeScript):
router.post(
  '/users',
  async (
    req: Request<{}, User, CreateUserBody>,
    res: Response<User | { error: string }>,
    next: NextFunction
  ) => {
    try {
      const { name, email, role = 'user' } = req.body;
      if (!name || !email) {
        res.status(400).json({ error: 'Name and email are required' });
        return;
      }
      const user: User = await db.createUser({ name, email, role });
      res.status(201).json(user);
    } catch (err) {
      next(err);
    }
  }
);

export default router;

React: Converting a component from JSX to TSX

// Input:  A React component in .jsx with prop-types
// Output: Same component in .tsx with TypeScript interfaces

import { useState } from 'react';

// BEFORE (JavaScript with PropTypes):
// import PropTypes from 'prop-types';
// function UserCard({ user, onEdit, showEmail }) { ... }
// UserCard.propTypes = { ... };

// AFTER (TypeScript):
interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string;
}

interface UserCardProps {
  user: User;
  onEdit?: (user: User) => void;
  showEmail?: boolean;
}

function UserCard({ user, onEdit, showEmail = true }: UserCardProps) {
  const [isEditing, setIsEditing] = useState(false);

  const handleSave = (updated: Partial<User>) => {
    onEdit?.({ ...user, ...updated });
    setIsEditing(false);
  };

  return (
    <div className="user-card">
      <h2>{user.name}</h2>
      {showEmail && <p>{user.email}</p>}
      {user.avatar && <img src={user.avatar} alt={user.name} />}
      {onEdit && <button onClick={() => setIsEditing(true)}>Edit</button>}
    </div>
  );
}

export default UserCard;

Configuration: tsconfig.json progression from permissive to strict

// === STAGE 1: Start migration (allowJs, no strict) ===
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "allowJs": true,
    "checkJs": false,
    "strict": false,
    "outDir": "./dist",
    "rootDir": "./src",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"]
}

// === STAGE 2: Most files converted (enable key checks) ===
// "allowJs": true, "checkJs": true,
// "noImplicitAny": true, "strictNullChecks": true

// === STAGE 3: Migration complete (full strict, no allowJs) ===
// "allowJs": false, "strict": true,
// "noUncheckedIndexedAccess": true, "exactOptionalProperties": true

Anti-Patterns

Wrong: Enabling strict: true on day one

// ❌ BAD — Generates hundreds of errors, stalls migration
// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "allowJs": true
  }
}
// Result: 500+ errors on a 10k LOC codebase, team gives up

Correct: Start permissive, tighten incrementally

// ✅ GOOD — Zero errors on day one, add strictness over weeks
// tsconfig.json
{
  "compilerOptions": {
    "strict": false,
    "allowJs": true,
    "noImplicitAny": false
  }
}
// Week 2: enable noImplicitAny
// Week 4: enable strictNullChecks
// Week 8: enable strict: true

Wrong: Using any everywhere to silence errors

// ❌ BAD — Defeats the purpose of TypeScript
function processData(data: any): any {
  return data.map((item: any) => ({
    name: item.name as any,
    value: item.count as any,
  }));
}

Correct: Use unknown and type narrowing

// ✅ GOOD — Forces you to validate types at runtime
function processData(data: unknown): ProcessedItem[] {
  if (!Array.isArray(data)) {
    throw new TypeError('Expected an array');
  }
  return data.map((item: unknown) => {
    if (typeof item !== 'object' || item === null) {
      throw new TypeError('Expected an object');
    }
    const obj = item as Record<string, unknown>;
    return {
      name: String(obj.name ?? ''),
      value: Number(obj.count ?? 0),
    };
  });
}

interface ProcessedItem {
  name: string;
  value: number;
}

Wrong: Renaming all files at once

# ❌ BAD — Renames everything, creates hundreds of errors simultaneously
find src -name "*.js" -exec sh -c 'mv "$1" "${1%.js}.ts"' _ {} \;
npx tsc --noEmit  # 847 errors

Correct: Rename one file at a time, fix, commit

# ✅ GOOD — One file per commit, zero errors at every step
mv src/utils/validators.js src/utils/validators.ts
npx tsc --noEmit   # fix the 3-5 errors in this file
git add -A && git commit -m "chore: convert validators.js to TypeScript"

Wrong: Creating a separate TypeScript directory

# ❌ BAD — Duplicates code, configs diverge, imports break
project/
├── src/           # old JavaScript
├── src-ts/        # new TypeScript copies
└── tsconfig.json

Correct: Convert in-place with allowJs

# ✅ GOOD — Single source tree, JS and TS coexist
project/
├── src/
│   ├── index.ts        # already converted
│   ├── api.js          # not yet converted
│   ├── utils/
│   │   ├── format.ts   # converted
│   │   └── parse.js    # not yet converted
│   └── types/
│       └── index.d.ts  # shared type definitions
└── tsconfig.json       # allowJs: true

Wrong: Ignoring third-party type definitions

// ❌ BAD — Importing untyped module gives implicit any
import csv from 'csv-parser';  // no @types/csv-parser exists
// csv is 'any', no autocomplete, no type checking
const results = csv();  // no error even if wrong

Correct: Create a declaration file for untyped packages

// ✅ GOOD — types/csv-parser.d.ts
declare module 'csv-parser' {
  import { Transform } from 'stream';

  interface CsvParserOptions {
    separator?: string;
    headers?: string[] | boolean;
    skipLines?: number;
  }

  function csvParser(options?: CsvParserOptions): Transform;
  export default csvParser;
}

Wrong: Using ts-migrate on a new project in 2026

# ❌ BAD — ts-migrate is unmaintained, generates excessive @ts-expect-error
npx ts-migrate full src/
# Result: compiles, but every file has @ts-expect-error and any types
# No one cleans them up, project has "TypeScript" but zero type safety

Correct: Use AI-assisted migration or manual incremental approach

# ✅ GOOD — AI tools (Cline, Cursor, Copilot) convert with real types
# Or for large codebases, write custom codemods like Stripe did
# 1. Convert one module at a time with AI assistance
# 2. Review generated types for correctness
# 3. Run tsc --noEmit to verify
# 4. Commit and move to next module

Common Pitfalls

Diagnostic Commands

# Check for type errors without emitting files
npx tsc --noEmit

# List all files included in the TypeScript compilation
npx tsc --listFiles

# Show the effective tsconfig.json (with all defaults)
npx tsc --showConfig

# Count remaining .js files (migration progress)
find src -name "*.js" | wc -l
find src -name "*.ts" -o -name "*.tsx" | wc -l

# Find files with @ts-ignore or @ts-expect-error (tech debt)
grep -rn "@ts-ignore\|@ts-expect-error" src/ --include="*.ts" --include="*.tsx"

# Dry-run strict mode to count errors before enabling
npx tsc --noEmit --strict 2>&1 | grep "error TS" | wc -l

# Run type checking in watch mode during development
npx tsc --noEmit --watch

# Test native Node.js TypeScript execution (Node 22.18+)
node --version  # must be >= 22.18.0
node src/index.ts  # runs with type stripping, no build step

# Compare TS 5.x vs 7.0 compilation speed
time npx tsc --noEmit    # TS 5.x/6.x
time npx tsgo --noEmit   # TS 7.0 (Go-based, ~10x faster)

Version History & Compatibility

VersionStatusBreaking ChangesMigration Notes
TypeScript 7.0 (Jan 2026)CurrentGo-based compiler (tsgo), strict-by-default, ES5 dropped, AMD/UMD removedMajor rewrite; ~10x faster builds; plan for strict compliance
TypeScript 6.0 (Oct 2025)LTSusing keyword for resource management, 30% faster buildsLast JS-based compiler; bridge to 7.0
TypeScript 5.9 (Aug 2025)Supportedimport defer syntax, node20 module resolutionSafe upgrade; improved default config
TypeScript 5.8 (Mar 2025)Supported--erasableSyntaxOnly flag; require() of ESM in nodenextSafe to migrate to; no breaking changes from 5.7
TypeScript 5.7 (Nov 2024)SupportedBetter uninitialized variable detectionMay surface new errors in existing code
TypeScript 5.5 (Jun 2024)SupportedInferred type predicatesImproves type narrowing automatically
TypeScript 5.0 (Mar 2023)Maintenance--moduleResolution bundler; decorators stage 3Use Node16 resolution for Node.js projects
TypeScript 4.x (2020–2022)EOL--strict expanded per releaseUpgrade to 5.x; minimal breaking changes

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Codebase is >1,000 LOC and growingOne-off script or prototype <100 LOCPlain JavaScript
Team has >2 developers on same codebaseSolo project with no plans to growJavaScript with JSDoc annotations
Project has complex data structures or APIsSimple static site with minimal JSVanilla JavaScript
You want editor autocomplete and refactoring toolsYou need zero build stepJSDoc + checkJs in tsconfig
Catching bugs before runtime is a priorityPerformance-critical hot path where tsc overhead mattersJavaScript + runtime validation (Zod)
Onboarding new developers faster with self-documenting typesTeam is unfamiliar with TypeScript and no time to learnJavaScript with ESLint strict rules
Node.js 22.18+ project — native TS means zero build overheadDeno project — Deno has native TS, no migration neededDeno's built-in TypeScript

Important Caveats

Related Units