How to Migrate a JavaScript Project to TypeScript
How do I migrate a JavaScript project to TypeScript?
TL;DR
- Bottom line: Migrate incrementally — add
tsconfig.jsonwithallowJs: true, rename.jsfiles to.tsone at a time starting with leaf modules, add type annotations, then progressively enable strict mode flags. With TypeScript 6.0 (Mar 23, 2026 — the last JS-based release) strict is now the default, so new migrations should target full strict compliance to be forward-compatible with the upcoming 7.0 Go-based compiler. [src10] - Key tool/command:
npx tsc --initto generatetsconfig.json, then set"allowJs": true, "strict": falseto begin. On Node.js 22.18+, you can run.tsfiles natively without a build step. - Watch out for: Enabling
strict: trueon day one — it generates hundreds of errors on any non-trivial codebase and stalls the migration. Also avoid the unmaintainedts-migratetool; prefer AI-assisted migration or manual incremental approach. - Works with: TypeScript 5.x and 6.0 (current stable, Mar 2026); 7.0 preview available via
@typescript/native-preview. Node.js 18+ (native TS support in 22.18+), any bundler (Webpack, Vite, esbuild, Rollup), React/Vue/Angular/Express.
Constraints
- TypeScript 6.0 (released Mar 23, 2026 — the last JavaScript-based compiler) enables
strict: trueby default. Projects migrating now should aim for full strict compliance to be forward-compatible with the upcoming Go-based 7.0. [src10] - Node.js 22.18+ supports native TypeScript execution via type stripping —
tscis still required for type checking, but not for running code. Do not confuse runtime support with compile-time safety. - Never enable
strict: trueon a large legacy codebase in a single step — enable individual strict flags incrementally (noImplicitAnyfirst, thenstrictNullChecks, etc.) to keep error counts manageable. [src6] - Do not mix module systems — use
"module": "nodenext"(TS 6.0 default) or"Node16", matching the"type"field inpackage.json.moduleResolution: "node"is deprecated in 6.0 — use"nodenext"or"bundler"instead. [src7, src11] - Airbnb's
ts-migrateis unmaintained (last commit 2023) and generates excessive@ts-expect-errorcomments — for large codebases (>50K LOC), prefer AI-assisted migration tools or Stripe's codemod approach. [src5, src8]
Quick Reference
| JavaScript Pattern | TypeScript Equivalent | Example |
|---|---|---|
function add(a, b) | Typed parameters | function add(a: number, b: number): number |
const obj = {} | Interface / type alias | const obj: User = { name: 'Alice', age: 30 } |
module.exports = fn | ESM export | export default fn or export { fn } |
require('pkg') | ESM import | import pkg from 'pkg' |
/** @param {string} name */ | JSDoc type annotation (bridge) | Works in .js files with checkJs: true |
// @ts-ignore | Suppress single line | Use // @ts-expect-error instead (errors if no issue exists) |
// @ts-nocheck | Suppress entire file | Place at top of file to skip type checking temporarily |
.js extension | .ts extension | Rename file, fix type errors, commit |
.jsx extension | .tsx extension | Rename, add prop types via interface Props {} |
any (implicit) | Explicit types or unknown | unknown 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 signature | Record<string, unknown> or { [key: string]: Value } |
callback(err, result) | Typed callbacks / generics | callback: (err: Error | null, result: T) => void |
Promise.all([...]) | Typed tuple inference | const [a, b] = await Promise.all([fetchA(), fetchB()]) auto-infers |
| Third-party library without types | Install @types/ or declare module | npm 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 6.0 (current, Mar 2026) → strict is default; plan for full compliance from start
│ ├── TS 7.0 (preview) → Go-based compiler; install via @typescript/native-preview to validate
│ └── TS 5.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):
noImplicitAny— forces explicit types on parametersstrictNullChecks— distinguishesnull/undefinedfrom other typesstrictFunctionTypes— enforces correct function parameter variancestrictBindCallApply— checks.bind(),.call(),.apply()argumentsnoImplicitThis— requires explicitthistypingstrictPropertyInitialization— enforces class property initializationalwaysStrict— emits"use strict"in output- Replace all individual flags with
"strict": true
Note: TypeScript 6.0 (Mar 2026) enables strict by default. You may instead start with "strict": true and selectively disable individual flags while fixing errors. The same applies to TS 7.0 (Go-based, preview).
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
- Implicit
anyin event handlers: TypeScript cannot infer the type ofeventin inline callbacks. Fix: explicitly type the event —(e: React.ChangeEvent<HTMLInputElement>) => .... [src2] - Module resolution mismatch: Using
"module": "commonjs"in tsconfig but"type": "module"in package.json causes conflicting module systems. Fix: use"module": "nodenext"(TS 6.0 default) or"Node16".moduleResolution: "node"is deprecated in 6.0 — use"nodenext"or"bundler". [src7, src11] - Missing
@typesfor test frameworks: Jest, Mocha, and Chai need separate type packages. Fix:npm i -D @types/jest. Without them,describe,it,expectare unresolved. [src2] Object.keys()returnsstring[], notkeyof T: TypeScript deliberately widens the return type. Fix: cast explicitly(Object.keys(obj) as Array<keyof typeof obj>). [src6]- Enum values not assignable to string: TypeScript enums create nominal types. Fix: use
as constobjects instead —const Status = { Active: 'active' } as const. [src1] - Default exports vs named exports confusion:
module.exports = fnmaps toexport default fn, butexports.fn = fnmaps toexport { fn }. Fix: useesModuleInterop: true. [src7] tsconfig.jsonnot including all source files: Files outside theincludeglob are silently ignored. Fix: verify withnpx tsc --listFiles | grep yourfile. [src4]skipLibCheck: truehiding real errors: Skips type checking of.d.tsfiles, including your own. Fix: periodically run withskipLibCheck: false. [src7]- Node.js native TS gives false confidence: Running
.tsfiles with Node 22.18+ strips types at runtime but does not type-check. Always runtsc --noEmitin CI. - TypeScript 6.0 strict default surprise: Projects that relied on
strict: falseas default will see new errors when upgrading to TS 6.0 (Mar 2026) or 7.0. Fix: explicitly set"strict": falsein tsconfig if not ready. [src10]
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
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| TypeScript 7.0 (Project Corsa, preview) | Preview | Go-based compiler (tsgo), ~10x faster builds, parallel project processing | Install via npm i -D @typescript/native-preview; validate compatibility before stable release |
| TypeScript 6.0 (Mar 23, 2026) | Current / final JS-based | strict: true default; module/target default to esnext/ES2025; moduleResolution: node deprecated; esModuleInterop can no longer be disabled; auto-include of @types/* removed | Bridge to 7.0; set target, module, moduleResolution explicitly; declare needed @types; run tsc --noEmit to surface errors |
| TypeScript 5.9 (Aug 2025) | Supported | import defer syntax, node20 module resolution | Safe upgrade; improved default config |
| TypeScript 5.8 (Mar 2025) | Supported | --erasableSyntaxOnly flag; require() of ESM in nodenext | Safe to migrate to; no breaking changes from 5.7 |
| TypeScript 5.7 (Nov 2024) | Supported | Better uninitialized variable detection | May surface new errors in existing code |
| TypeScript 5.5 (Jun 2024) | Supported | Inferred type predicates | Improves type narrowing automatically |
| TypeScript 5.0 (Mar 2023) | Maintenance | --moduleResolution bundler; decorators stage 3 | Use Node16 resolution for Node.js projects |
| TypeScript 4.x (2020–2022) | EOL | --strict expanded per release | Upgrade to 5.x; minimal breaking changes |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Codebase is >1,000 LOC and growing | One-off script or prototype <100 LOC | Plain JavaScript |
| Team has >2 developers on same codebase | Solo project with no plans to grow | JavaScript with JSDoc annotations |
| Project has complex data structures or APIs | Simple static site with minimal JS | Vanilla JavaScript |
| You want editor autocomplete and refactoring tools | You need zero build step | JSDoc + checkJs in tsconfig |
| Catching bugs before runtime is a priority | Performance-critical hot path where tsc overhead matters | JavaScript + runtime validation (Zod) |
| Onboarding new developers faster with self-documenting types | Team is unfamiliar with TypeScript and no time to learn | JavaScript with ESLint strict rules |
| Node.js 22.18+ project — native TS means zero build overhead | Deno project — Deno has native TS, no migration needed | Deno's built-in TypeScript |
Important Caveats
- TypeScript 6.0 (released Mar 23, 2026 — the last JavaScript-based compiler) enables
strict: trueby default. TypeScript 7.0 (Project Corsa, Go-based) is in preview as of May 2026 vianpm i -D @typescript/native-preview— it delivers ~10x faster builds but has not shipped a stable release yet. Projects migrating now should target full strict compliance to avoid breakage on the 7.0 upgrade. [src9, src10] allowJs: trueadds compilation overhead — TypeScript must parse and check both JS and TS files. On very large codebases (>100k LOC), consider using project references to split compilation.- The
tsconfig.jsonsettings"module": "Node16"and"moduleResolution": "Node16"are the correct settings for modern Node.js. TypeScript 5.9+ defaults to"nodenext"which is also acceptable. Do not use the older"commonjs"+"node"combination. [src7] skipLibCheck: trueis recommended during migration for speed, but should be disabled periodically to catch declaration file bugs.- Airbnb's
ts-migrateis no longer maintained (last commit 2023). It generates compilable TypeScript with many@ts-expect-errorcomments andanytypes. Consider AI-assisted migration tools for better type quality. [src5] - TypeScript does not perform runtime type checking. Pair with Zod or io-ts for external data validation (API responses, user input).
- Node.js native TypeScript support (22.18+) strips types at runtime but does not type-check. Always run
tsc --noEmitin CI — do not rely solely on Node.js execution for correctness. - Stripe migrated 3.7 million lines from Flow to TypeScript in a single PR using custom codemods. For very large codebases, consider a similar all-at-once approach with automated tooling. [src8]