TypeScript 96 🔷 Migrating JavaScript to TypeScript
Every TypeScript project was once a JavaScript project. Some were started fresh with TypeScript from the first commit. Most were not. The typical migration story is a JavaScript codebase that has grown large enough that the absence of types has become a liability — bugs that only surface at runtime, refactoring that is risky because nothing checks the call sites, and onboarding that takes weeks because the shape of every object must be inferred from usage rather than read from a declaration.
This chapter covers the process of migrating a JavaScript codebase to TypeScript incrementally. The naive approach — renaming every .js file to .ts and fixing the thousands of compiler errors that follow — is almost always wrong. It stalls development, produces an enormous pull request that no one can review, and creates a backlog of type errors that lingers for months. The incremental approach is the one that works: enable TypeScript without changing any files, allow JavaScript and TypeScript to coexist, migrate file by file, and tighten the compiler settings as the codebase becomes typed.
Key point: TypeScript’s design allows a JavaScript project to adopt it gradually. The allowJs option lets .js files be part of a TypeScript project. The checkJs option enables type checking for JavaScript files that have JSDoc annotations. The strict option can be turned on later, after the codebase has been migrated. The compiler’s error count is a progress metric, not a blocker — you can migrate file by file and track the number of remaining errors as it falls .
Why incremental migration matters
The choice between “big bang” and “incremental” migration is the most consequential decision in the process. The two approaches produce very different outcomes.
The big bang problem. A team renames every .js file to .ts, runs the compiler, and gets 4,000 errors. The errors are a mix of genuine type issues, implicit any annotations that strict mode rejects, third-party library types that are missing or wrong, and patterns that TypeScript does not support. No one can review the resulting pull request. Development stalls while the team fixes the errors. The migration takes weeks and the codebase is frozen in the meantime. If the team gives up halfway, it is left with a partially migrated codebase and a pile of @ts-ignore comments.
The incremental solution. The team adds a tsconfig.json with allowJs: true and noEmit: true. No files are renamed. No code is changed. The compiler now checks the project, and the team sees the existing state. Then the team migrates one file at a time: rename it to .ts, fix the errors in that file, and commit. The rest of the codebase continues to work as JavaScript. The error count falls file by file. The team chooses when to enable strict — after the migration is complete, not before it starts.
The coexistence problem. During the migration, the codebase has both .js and .ts files. TypeScript can import from JavaScript files. JavaScript files cannot import from TypeScript files with full type information, but the allowJs option lets them import at runtime. The esModuleInterop and allowSyntheticDefaultImports options handle the module interop between the two. The coexistence is temporary, but it is the reason the migration can be incremental.
The strictness problem. The strict option is a bundle of eight compiler flags. Turning it on all at once makes the migration much harder because every untyped variable becomes an error. The recommended approach is to start with strict: false and enable the individual flags — noImplicitAny, strictNullChecks, strictFunctionTypes — one at a time as the codebase allows. The strict option is the destination, not the starting point.
The trade-off. Incremental migration takes longer in calendar time than big bang migration would if it succeeded. But big bang migration rarely succeeds. The incremental approach allows the team to keep shipping features while the migration happens. The total work is similar; the risk is far lower.
a. Setting Up the Project for Migration
The migration starts with a tsconfig.json that allows JavaScript files and does not emit output. This configuration makes the project a TypeScript project without requiring any code changes.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowJs": true,
"checkJs": false,
"noEmit": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
The allowJs: true option allows JavaScript files to be part of the compilation. The checkJs: false option means JavaScript files are not type-checked. This is the starting point — the project compiles, no errors, no changes required.
The noEmit: true option tells the compiler not to write output. The existing build tool — Vite, webpack, esbuild, or whatever the project uses — continues to produce the actual bundle. TypeScript is only checking types, not emitting.
The checkJs option can be turned on later, file by file, to type-check JavaScript files that have JSDoc annotations. Some teams start with checkJs: false and enable it for selected files as part of the migration. Others leave it off entirely and only check .ts files.
The installation adds TypeScript and the type definitions for the runtime and libraries:
npm install --save-dev typescript @types/node
If the project uses React, install @types/react and @types/react-dom. If it uses Express, install @types/express. The pattern is @types/<package> for every dependency that does not ship its own types.
b. Migrating Files One at a Time
The migration of an individual file follows a consistent pattern. The file is renamed from .js to .ts. The compiler reports errors. The errors are fixed. The file is committed. The next file is started.
The errors that appear fall into a small number of categories.
Implicit any errors. When noImplicitAny is enabled, every parameter without a type annotation becomes an error. The fix is to add the annotation. If the correct type is not known, unknown is safer than any because it forces the caller to narrow the type before using it.
// Before (JavaScript)
function formatUser(user) {
return `${user.firstName} ${user.lastName}`;
}
// After (TypeScript)
interface User {
firstName: string;
lastName: string;
}
function formatUser(user: User): string {
return `${user.firstName} ${user.lastName}`;
}
Object shape errors. A function that takes an object and reads properties from it needs an interface for the object. The interface is the documentation of the shape. The compiler then checks every call site against the interface.
// Before
function createOrder(user, items) {
return { user, items, total: items.reduce((sum, i) => sum + i.price, 0) };
}
// After
interface User { id: string; name: string; }
interface OrderItem { id: string; price: number; quantity: number; }
interface Order {
user: User;
items: OrderItem[];
total: number;
}
function createOrder(user: User, items: OrderItem[]): Order {
return {
user,
items,
total: items.reduce((sum, i) => sum + i.price * i.quantity, 0),
};
}
Third-party type errors. A dependency that does not ship its own types needs @types/<package>. If the types do not exist, a local .d.ts file declares the module with a minimal shape, or the import is marked with // @ts-expect-error for the moment. The @ts-expect-error comment is temporary — it should be resolved before the migration is complete.
// A library with no types
// @ts-expect-error — no types available
import legacyLib from 'legacy-lib';
// Or declare a minimal module
// src/types/legacy-lib.d.ts
declare module 'legacy-lib' {
export function doSomething(input: string): number;
}
Strict null errors. When strictNullChecks is enabled, null and undefined become distinct types. A function that returns User | null requires the caller to check for null before using the user. This is the flag that catches the most real bugs and the one that requires the most code changes.
// Before strictNullChecks
function findUser(id: string): User {
return users.find(u => u.id === id);
}
// After strictNullChecks
function findUser(id: string): User | undefined {
return users.find(u => u.id === id);
}
const user = findUser('1');
if (!user) {
throw new Error('User not found');
}
// user is User here
Implicit return errors. When noImplicitReturns is enabled, every code path must return a value. The fix is to add the missing return or restructure the function.
Duplicate identifier errors. A file that uses a variable name that conflicts with a global or an imported name needs renaming. The compiler reports the conflict, and the fix is local.
The migration of one file typically takes ten minutes to an hour, depending on the file’s complexity. The file is committed with a message like “Migrate user service to TypeScript.” The next file is started. Over weeks or months, the number of .js files falls to zero, and the migration is complete.
c. Tightening the Compiler Settings
The migration does not end when the last file is renamed. The compiler settings determine how strict the codebase is, and the strictness increases after the migration.
The recommended progression of tsconfig.json settings:
Phase 1: Allow JavaScript, check nothing.
{ "allowJs": true, "checkJs": false, "strict": false, "noEmit": true }
Phase 2: Allow JavaScript, check JavaScript with JSDoc.
{ "allowJs": true, "checkJs": true, "strict": false, "noEmit": true }
Phase 3: Migrate files, enable noImplicitAny.
{ "allowJs": true, "checkJs": false, "strict": false, "noImplicitAny": true, "noEmit": true }
Phase 4: Enable strictNullChecks and strictFunctionTypes.
{ "strict": false, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true }
Phase 5: Enable strict (the full bundle).
{ "strict": true }
Phase 6: Remove allowJs.
{ "allowJs": false, "strict": true }
Each phase is a milestone. The codebase compiles at each phase. The team chooses when to move to the next phase based on the codebase’s readiness and the team’s capacity to fix the new errors.
The strict option is a bundle of eight flags: noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, alwaysStrict, and useUnknownInCatchVariables. Turning on strict is equivalent to turning on all eight. The migration can enable them individually before enabling the bundle.
The final step is to add a type-check script to CI:
{
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run"
}
}
The tsc --noEmit command runs the compiler in check-only mode. In CI, this step runs on every pull request. A pull request that introduces a type error cannot be merged. This is the mechanism that prevents the migrated codebase from regressing.
Complete Example Session
This session migrates a small JavaScript project to TypeScript, one file at a time, and tracks the compiler settings at each stage.
// ============================================
// PART 1: THE JAVASCRIPT PROJECT
// ============================================
// src/models/user.js
export function createUser(id, name, email) {
return { id, name, email };
}
// src/services/user.service.js
import { createUser } from '../models/user.js';
const users = [];
export function addUser(id, name, email) {
const user = createUser(id, name, email);
users.push(user);
return user;
}
export function findUser(id) {
return users.find(u => u.id === id);
}
export function getUserName(id) {
const user = findUser(id);
return user.name;
}
// src/index.js
import { addUser, getUserName } from './services/user.service.js';
addUser('1', 'Alice', 'alice@example.com');
console.log(getUserName('1'));
// ============================================
// PART 2: PHASE 1 — TSCONFIG WITH allowJs
// ============================================
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowJs": true,
"checkJs": false,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
// Run: npx tsc --noEmit
// Result: no errors. The project compiles as JavaScript.
// ============================================
// PART 3: PHASE 2 — MIGRATE THE MODEL
// ============================================
// Rename src/models/user.js to src/models/user.ts
// src/models/user.ts
export interface User {
id: string;
name: string;
email: string;
}
export function createUser(id: string, name: string, email: string): User {
return { id, name, email };
}
// Run: npx tsc --noEmit
// Result: no errors. The JavaScript files import the TS file
// through the allowJs option.
// ============================================
// PART 4: PHASE 3 — MIGRATE THE SERVICE
// ============================================
// Rename src/services/user.service.js to user.service.ts
// src/services/user.service.ts
import { createUser, User } from '../models/user';
const users: User[] = [];
export function addUser(id: string, name: string, email: string): User {
const user = createUser(id, name, email);
users.push(user);
return user;
}
export function findUser(id: string): User | undefined {
return users.find(u => u.id === id);
}
export function getUserName(id: string): string {
const user = findUser(id);
if (!user) {
throw new Error(`User ${id} not found`);
}
return user.name;
}
// Run: npx tsc --noEmit
// Result: no errors.
// ============================================
// PART 5: PHASE 4 — MIGRATE THE ENTRY POINT
// ============================================
// Rename src/index.js to src/index.ts
// src/index.ts
import { addUser, getUserName } from './services/user.service';
addUser('1', 'Alice', 'alice@example.com');
console.log(getUserName('1'));
// Run: npx tsc --noEmit
// Result: no errors. All source files are now TypeScript.
// ============================================
// PART 6: PHASE 5 — ENABLE noImplicitAny
// ============================================
// tsconfig.json
{
"compilerOptions": {
"strict": false,
"noImplicitAny": true,
"strictNullChecks": false,
"allowJs": false,
"noEmit": true
}
}
// Run: npx tsc --noEmit
// Result: errors for any untyped parameters.
// Fix each one by adding a type annotation.
// ============================================
// PART 7: PHASE 6 — ENABLE strictNullChecks
// ============================================
// tsconfig.json
{
"compilerOptions": {
"strict": false,
"noImplicitAny": true,
"strictNullChecks": true,
"allowJs": false,
"noEmit": true
}
}
// Run: npx tsc --noEmit
// Result: errors for every value that might be null or undefined.
// The findUser return type is already User | undefined.
// The getUserName function checks for undefined and throws.
// The codebase compiles.
// ============================================
// PART 8: PHASE 7 — ENABLE strict
// ============================================
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"allowJs": false,
"noEmit": true
}
}
// Run: npx tsc --noEmit
// Result: no errors. The codebase is fully strict.
// ============================================
// PART 9: PHASE 8 — ADD TO CI
// ============================================
// package.json
{
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run"
}
}
// .github/workflows/ci.yml
// - run: npm run typecheck
// - run: npm run lint
// - run: npm run test
// ============================================
// PART 10: THE MIGRATION CHECKLIST
// ============================================
// 1. Install typescript and @types/* packages
// 2. Create tsconfig.json with allowJs: true
// 3. Run tsc --noEmit to verify no errors
// 4. Add typecheck script to CI
// 5. Migrate files one at a time
// 6. Rename .js to .ts
// 7. Fix the errors in that file
// 8. Commit the migrated file
// 9. Repeat until no .js files remain
// 10. Enable stricter compiler flags
// 11. Remove allowJs from tsconfig
// 12. Keep the typecheck script in CI forever
The ten parts cover the JavaScript project, Phase 1 (tsconfig with allowJs), Phase 2 (migrate the model), Phase 3 (migrate the service), Phase 4 (migrate the entry point), Phase 5 (noImplicitAny), Phase 6 (strictNullChecks), Phase 7 (strict), Phase 8 (add to CI), and the migration checklist.
Quick Reference
The Compiler Options for Migration
| Option | Purpose | When to Enable |
|---|---|---|
allowJs | Include .js files | Day 1 |
checkJs | Check .js files | Optional |
noEmit | Check only, no output | Day 1 |
noImplicitAny | Error on untyped params | After most files are .ts |
strictNullChecks | Distinguish null/undefined | After noImplicitAny |
strict | All strict flags | At the end |
esModuleInterop | CJS/ESM compatibility | Day 1 |
The Migration Phases
| Phase | Files | Compiler Settings | Error Count |
|---|---|---|---|
| 1 | All .js | allowJs: true, strict: false | 0 |
| 2 | Some .ts | allowJs: true, strict: false | 0 |
| 3 | Most .ts | noImplicitAny: true | Falls |
| 4 | All .ts | strictNullChecks: true | Falls |
| 5 | All .ts | strict: true | 0 |
| 6 | All .ts | allowJs: false | 0 |
The Common Error Categories
| Error | Fix |
|---|---|
noImplicitAny | Add type annotation or unknown |
| Object shape unknown | Declare an interface |
| Missing library types | Install @types/<package> or declare a module |
strictNullChecks | Add null checks |
noImplicitReturns | Add the missing return |
| Duplicate identifier | Rename the conflicting name |
The Useful Commands
| Command | Purpose |
|---|---|
npx tsc --noEmit | Run the type checker |
npx tsc --noEmit --watch | Watch mode |
npx tsc --noEmit --pretty | Colored output |
npx tsc --init | Generate a tsconfig |
Best Practices
✅ Do This:
// Start with allowJs, noEmit, strict: false
{ "allowJs": true, "noEmit": true, "strict": false } // ✅
// Use unknown instead of any when the type is unknown
function parse(data: unknown): User { ... } // ✅
// Declare interfaces for object shapes
interface User { id: string; name: string; } // ✅
# Migrate one file at a time
git mv user.js user.ts # ✅
// Run tsc --noEmit in CI
"typecheck": "tsc --noEmit" // ✅
❌ Don’t Do This:
# Don't rename every file at once
git mv src/*.js src/*.ts // ❌
// Don't enable strict on day one
{ "strict": true, "allowJs": true } // ❌
// Don't use any to silence errors
function parse(data: any) { ... } // ❌
// Don't leave @ts-expect-error comments permanently
// @ts-expect-error // TODO: fix types // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Migration stalls | Big bang approach with too many errors | Migrate file by file |
| Error count grows | Enabling strict too early | Enable strict last |
| Missing library types | @types/* not installed | Install or declare a module |
Build fails on .js imports | allowJs not enabled | Add to tsconfig.json |
| Regressions slip in | No CI type check | Add tsc --noEmit to CI |
any accumulates | Using any to silence errors | Use unknown and narrow |
Real-World Examples
1. Starting tsconfig
{ "compilerOptions": { "allowJs": true, "noEmit": true, "strict": false } }
2. Migrate One File
git mv user.js user.ts
npx tsc --noEmit
# fix errors
git commit -m "Migrate user.js to TypeScript"
3. Add Type Annotation
function greet(name: string): string { return `Hello, ${name}`; }
4. Declare an Interface
interface Order { id: string; items: OrderItem[]; }
5. Install Library Types
npm install --save-dev @types/express
6. Declare a Module
declare module 'legacy-lib' {
export function doSomething(input: string): number;
}
7. Enable noImplicitAny
{ "noImplicitAny": true }
8. Enable strictNullChecks
{ "strictNullChecks": true }
9. Enable strict
{ "strict": true }
10. Add CI Step
- run: npx tsc --noEmit
Visual
The Migration Phases
┌──────────────────────────────────────────────┐
│ INCREMENTAL MIGRATION │
│ │
│ Phase 1: .js .js .js .js .js .js │
│ allowJs: true, strict: false │
│ Errors: 0 │
│ │
│ Phase 2: .ts .js .js .ts .js .js │
│ Migrate one at a time │
│ Errors: 0 (or falls) │
│ │
│ Phase 3: .ts .ts .ts .ts .ts .ts │
│ Enable noImplicitAny │
│ Errors: falls as types added │
│ │
│ Phase 4: Enable strictNullChecks │
│ Errors: falls as null checks added│
│ │
│ Phase 5: Enable strict: true │
│ Errors: 0 │
│ │
│ Phase 6: allowJs: false │
│ Migration complete │
│ │
└──────────────────────────────────────────────┘
The File Migration Flow
┌──────────────────────────────────────────────┐
│ ONE FILE AT A TIME │
│ │
│ git mv user.js user.ts │
│ │ │
│ ▼ │
│ npx tsc --noEmit │
│ │ │
│ ▼ │
│ Fix errors: │
│ ├─ Add type annotations │
│ ├─ Declare interfaces │
│ ├─ Handle null/undefined │
│ └─ Install @types │
│ │ │
│ ▼ │
│ npx tsc --noEmit → 0 errors │
│ │ │
│ ▼ │
│ git commit -m "Migrate user.js to TS" │
│ │ │
│ ▼ │
│ Next file │
│ │
└──────────────────────────────────────────────┘
The Strictness Progression
┌──────────────────────────────────────────────┐
│ STRICTNESS OVER TIME │
│ │
│ strict: false │
│ ├─ noImplicitAny: false │
│ ├─ strictNullChecks: false │
│ └─ strictFunctionTypes: false │
│ │
│ Phase 3: │
│ └─ noImplicitAny: true │
│ │
│ Phase 4: │
│ ├─ noImplicitAny: true │
│ ├─ strictNullChecks: true │
│ └─ strictFunctionTypes: true │
│ │
│ Phase 5: │
│ └─ strict: true (all flags) │
│ │
│ Each phase reveals new errors. │
│ The codebase is fixed before the next phase.│
│ │
└──────────────────────────────────────────────┘
The CI Check
┌──────────────────────────────────────────────┐
│ CI PIPELINE │
│ │
│ Push / PR │
│ │ │
│ ▼ │
│ npm ci │
│ │ │
│ ▼ │
│ tsc --noEmit ──> type errors? │
│ │ │
│ ├─ YES → block merge │
│ └─ NO → continue │
│ │ │
│ ▼ │
│ eslint . ──> lint errors? │
│ │ │
│ ├─ YES → block merge │
│ └─ NO → continue │
│ │ │
│ ▼ │
│ vitest run ──> test failures? │
│ │ │
│ ├─ YES → block merge │
│ └─ NO → merge allowed │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Migration approach | Incremental, file by file |
| Starting config | allowJs: true, noEmit: true, strict: false |
| First step | Add tsconfig.json, verify zero errors |
| File migration | Rename .js to .ts, fix errors, commit |
| Library types | @types/<package> or local .d.ts |
unknown vs any | Prefer unknown when the type is not known |
| Strictness order | noImplicitAny → strictNullChecks → strict |
| Final step | Remove allowJs, keep tsc --noEmit in CI |
| CI command | tsc --noEmit on every pull request |
| Error count | A progress metric, not a blocker |
Key takeaways:
- Incremental migration is the only approach that works at scale. Renaming every file at once produces thousands of errors, stalls development, and creates a pull request that cannot be reviewed. Migrating one file at a time keeps the codebase shippable throughout the process.
- The starting configuration allows JavaScript and checks nothing.
allowJs: true,checkJs: false,strict: false,noEmit: true. The project compiles with zero errors and no code changes. The type checker is added to CI as a non-blocking step at first. - Each file is renamed from
.jsto.tsand its errors are fixed. The errors are implicitanyannotations, unknown object shapes, missing library types, and null checks. The fixes are type annotations, interface declarations,@typesinstalls, and null guards. - The strictness increases after the migration, not before it.
noImplicitAnyis enabled first, thenstrictNullChecks, thenstrict. Each flag reveals a new category of errors that the team fixes before enabling the next flag. unknownis the correct replacement forany. When the type of a value is genuinely not known,unknownforces the caller to narrow it before using it.anydisables type checking for the entire expression and everything it touches.- Library types come from
@types/<package>or local.d.tsfiles. If the package ships its own types, no installation is needed. If not, the@typespackage is the first choice. If neither exists, a local declaration file with a minimal shape is the fallback. - CI is the mechanism that prevents regression. The
tsc --noEmitcommand runs on every pull request. A type error introduced by a new change is caught before it merges. Once the migration is complete, this check remains in place permanently.
Remember: Migrating from JavaScript to TypeScript is a process, not an event. The goal is not to have zero errors on day one — it is to have a codebase that compiles, a CI check that prevents regressions, and a strictness level that increases as the codebase becomes typed. Rename one file, fix its errors, commit it. Then do the next one. The error count falls. The strictness rises. The codebase becomes safer with every file. The migration ends when the last .js file becomes .ts and the last strict flag is enabled. Until then, the project is a TypeScript project with a JavaScript history — and that is exactly how it should be.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!