tsconfig.json with allowJs: true, rename .js files to .ts one at a time starting with leaf modules, add type annotations, then progressively enable strict mode flags. With TypeScript 7.0 (Jan 2026) strict is now the default, so new migrations should target full strict compliance.npx tsc --init to generate tsconfig.json, then set "allowJs": true, "strict": false to begin. On Node.js 22.18+, you can run .ts files natively without a build step.strict: true on day one — it generates hundreds of errors on any non-trivial codebase and stalls the migration. Also avoid the unmaintained ts-migrate tool; prefer AI-assisted migration or manual incremental approach.strict: true by default — projects migrating now should aim for full strict compliance to be forward-compatible. [src9]tsc is still required for type checking, but not for running code. Do not confuse runtime support with compile-time safety.strict: true on a large legacy codebase in a single step — enable individual strict flags incrementally (noImplicitAny first, then strictNullChecks, etc.) to keep error counts manageable. [src6]"module": "Node16" or "nodenext" and "moduleResolution": "Node16" or "nodenext" to match the "type" field in package.json. [src7]ts-migrate is unmaintained (last commit 2023) and generates excessive @ts-expect-error comments — for large codebases (>50K LOC), prefer AI-assisted migration tools or Stripe's codemod approach. [src5, src8]| 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 |
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
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).
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.
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.
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.
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.
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 — distinguishes null/undefined from other typesstrictFunctionTypes — enforces correct function parameter variancestrictBindCallApply — checks .bind(), .call(), .apply() argumentsnoImplicitThis — requires explicit this typingstrictPropertyInitialization — enforces class property initializationalwaysStrict — emits "use strict" in output"strict": trueNote: 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.
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.
// 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;
// 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;
// === 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
// ❌ 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
// ✅ 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
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,
}));
}
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;
}
# ❌ 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
# ✅ 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"
# ❌ BAD — Duplicates code, configs diverge, imports break
project/
├── src/ # old JavaScript
├── src-ts/ # new TypeScript copies
└── tsconfig.json
# ✅ 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
// ❌ 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
// ✅ 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;
}
# ❌ 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
# ✅ 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
any in event handlers: TypeScript cannot infer the type of event in inline callbacks. Fix: explicitly type the event — (e: React.ChangeEvent<HTMLInputElement>) => .... [src2]"module": "commonjs" in tsconfig but "type": "module" in package.json causes conflicting module systems. Fix: use "module": "Node16" and "moduleResolution": "Node16". [src7]@types for test frameworks: Jest, Mocha, and Chai need separate type packages. Fix: npm i -D @types/jest. Without them, describe, it, expect are unresolved. [src2]Object.keys() returns string[], not keyof T: TypeScript deliberately widens the return type. Fix: cast explicitly (Object.keys(obj) as Array<keyof typeof obj>). [src6]as const objects instead — const Status = { Active: 'active' } as const. [src1]module.exports = fn maps to export default fn, but exports.fn = fn maps to export { fn }. Fix: use esModuleInterop: true. [src7]tsconfig.json not including all source files: Files outside the include glob are silently ignored. Fix: verify with npx tsc --listFiles | grep yourfile. [src4]skipLibCheck: true hiding real errors: Skips type checking of .d.ts files, including your own. Fix: periodically run with skipLibCheck: false. [src7].ts files with Node 22.18+ strips types at runtime but does not type-check. Always run tsc --noEmit in CI.strict: false as default will see new errors when upgrading to TS 7.0. Fix: explicitly set "strict": false in tsconfig if not ready. [src9]# 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 | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| TypeScript 7.0 (Jan 2026) | Current | Go-based compiler (tsgo), strict-by-default, ES5 dropped, AMD/UMD removed | Major rewrite; ~10x faster builds; plan for strict compliance |
| TypeScript 6.0 (Oct 2025) | LTS | using keyword for resource management, 30% faster builds | Last JS-based compiler; bridge to 7.0 |
| 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 |
| 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 |
strict: true by default and uses a Go-based compiler (tsgo). Projects migrating now should aim for full strict compliance. The Go-based compiler delivers ~10x faster builds but drops ES5 and AMD/UMD/SystemJS support. [src9]allowJs: true adds 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.tsconfig.json settings "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: true is recommended during migration for speed, but should be disabled periodically to catch declaration file bugs.ts-migrate is no longer maintained (last commit 2023). It generates compilable TypeScript with many @ts-expect-error comments and any types. Consider AI-assisted migration tools for better type quality. [src5]tsc --noEmit in CI — do not rely solely on Node.js execution for correctness.