Migrating from JavaScript
Migrating a JavaScript project to TypeScript does not have to be done all at once. TypeScript is designed to be adopted incrementally — you can add it to an existing JS project, convert files one at a time, and tighten strictness gradually. This page walks through every step of a real-world migration.
Why Migrate?
Catch bugs at compile time, before they reach production
IDE autocompletion, refactoring, and go-to-definition across the whole codebase
Self-documenting code — types serve as always-accurate API docs
Safer refactoring — rename a function and TypeScript shows every call site
Easier onboarding for new team members
Migration Strategy Overview
Install TypeScript and create tsconfig.json with allowJs and lenient settings
Run the TypeScript compiler in check-only mode to see the baseline error count
Rename .js files to .ts one module at a time (start with leaf modules — those with no imports)
Fix type errors in each converted file before moving to the next
Enable stricter compiler options incrementally as the codebase improves
Reach strict: true as the final goal
allowJs option lets TypeScript compile both file types simultaneously.Step 1 — Install TypeScript
npm install --save-dev typescript @types/node # Create a tsconfig.json npx tsc --init
Step 2 — tsconfig for Mixed JS/TS Projects
Start with a permissive tsconfig that lets you compile the existing JavaScript while you gradually convert files.
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
// Allow .js files to be compiled alongside .ts
"allowJs": true,
// Type-check .js files (optional but recommended during migration)
"checkJs": false,
// How deep to look for types in node_modules
"maxNodeModuleJsDepth": 1,
// Keep strict OFF initially — you will turn these on one by one
"strict": false,
"noImplicitAny": false,
"strictNullChecks": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}Step 3 — Check Baseline Errors
# Type-check without emitting files npx tsc --noEmit # Count errors to see your starting point npx tsc --noEmit 2>&1 | grep "error TS" | wc -l
42
npx tsc --noEmit in watch mode while converting files so you see errors update in real time.Step 4 — Rename .js to .ts
Start with leaf modules — files that do not import other project files. These are the easiest to convert because you only need to worry about the module's own types, not the types of its dependencies.
# Rename a single file
mv src/utils/formatDate.js src/utils/formatDate.ts
# Or batch-rename all .js files (do this only when ready to commit fully)
find src -name "*.js" -not -path "*/node_modules/*" | while read f; do
mv "$f" "${f%.js}.ts"
done.jsx files to .tsx, not .ts. TypeScript requires the .tsx extension for files containing JSX.Fixing Implicit any Annotations
The most common migration errors come from parameters and variables that TypeScript cannot infer a type for. With noImplicitAny: false these are silently any; when you turn it on, you must annotate them.
// Before migration (JavaScript)
function processUser(user) {
return user.name.toUpperCase()
}
// After migration — Option 1: explicit type
interface User {
name: string
age: number
}
function processUser(user: User): string {
return user.name.toUpperCase()
}
// After migration — Option 2: inline type
function processUser2(user: { name: string; age: number }): string {
return user.name.toUpperCase()
}
// Temporary fallback during migration — explicit any
// (better than implicit any because it is intentional)
function processUser3(user: any): string {
return user.name.toUpperCase()
}Dealing with Third-Party Types (@types)
Many popular npm packages ship type definitions. For those that do not, the DefinitelyTyped project (@types/*) provides community-maintained declarations.
# Check if a package includes types npm info lodash types # Install types for packages without built-in declarations npm install --save-dev @types/lodash @types/express @types/node # See all available @types packages npm search @types/
// After installing @types/lodash, imports are fully typed
import _ from 'lodash'
const users = [
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 25 },
]
// TypeScript knows the return type is typeof users[0][]
const sorted = _.sortBy(users, 'age')
console.log(sorted[0].name) // Alice — fully typedWhen @types Does Not Exist
For packages with no type definitions at all, you can write a minimal declaration file to silence TypeScript errors and add just enough typing for your usage.
// src/types/some-untyped-package.d.ts
declare module 'some-untyped-package' {
// Export what you actually use — can be minimal
export function parse(input: string): Record<string, unknown>
export function stringify(value: unknown): string
interface Options {
pretty?: boolean
strict?: boolean
}
export function format(value: unknown, options?: Options): string
}
// Or — the nuclear option: accept any for now
declare module 'some-untyped-package'JSDoc Types as an Intermediate Step
If you cannot rename .js files immediately (e.g., build tool constraints), you can add JSDoc type annotations and enable checkJs: true. TypeScript understands JSDoc and provides full type checking in .js files.
// @ts-check (enables TS checking in this single file)
/**
* @param {string} name
* @param {number} age
* @returns {{ name: string; age: number; id: string }}
*/
function createUser(name, age) {
return { name, age, id: Math.random().toString(36) }
}
/**
* @typedef {Object} Config
* @property {string} host
* @property {number} port
* @property {boolean} [ssl]
*/
/** @type {Config} */
const config = {
host: 'localhost',
port: 3000,
}Common Migration Errors and Fixes
Error | Cause | Fix |
|---|---|---|
Parameter X implicitly has an any type | noImplicitAny is on, no type annotation | Add explicit type annotation |
Object is possibly null or undefined | strictNullChecks is on | Add null check or use optional chaining |
Property X does not exist on type Y | Accessing a property TS does not know about | Add the property to the interface or use a type guard |
Cannot find module X or its type declarations | Missing @types package or .d.ts file | npm install @types/X or create a .d.ts declaration |
X is not assignable to type Y | Type mismatch between assigned value and declared type | Fix the type annotation or add a type assertion with explanation |
this implicitly has type 'any' | noImplicitThis is on, this is used in a function | Add a this parameter: function f(this: MyClass) |
Strict Mode Migration Path
Do not try to enable strict: true all at once on a large codebase. Enable each flag individually in order of impact.
strictNullChecks — fixes null/undefined bugs, highest value
noImplicitAny — forces explicit types on all parameters
strictFunctionTypes — correct variance for function parameters
strictPropertyInitialization — class properties must be set in constructor
noImplicitThis — forces explicit this parameter type
strictBindCallApply — types bind/call/apply correctly
alwaysStrict — emits "use strict" in output and enables strict parsing
Set strict: true once all individual flags pass — it enables all of the above
// Incremental tsconfig — Phase 1
{
"compilerOptions": {
"strictNullChecks": true
// All other strict flags remain off
}
}
// Phase 2 — after all null errors are fixed
{
"compilerOptions": {
"strictNullChecks": true,
"noImplicitAny": true
}
}
// Final goal
{
"compilerOptions": {
"strict": true
}
}Handling Dynamic Patterns TypeScript Cannot Infer
Some JavaScript patterns are inherently dynamic and cannot be automatically typed by TypeScript. Here are the most common and how to handle them.
// Pattern 1: Dynamic property access
const key = 'name' // TypeScript widens this to string
const user = { name: 'Alice', age: 30 }
// Error: Element implicitly has an 'any' type
// const val = user[key]
// Fix: use a type assertion on the key
const val = user[key as keyof typeof user] // string | number
// Pattern 2: JSON.parse returns any
const raw = JSON.parse('{ "name": "Alice" }')
// raw is any — unsafe
// Fix: use a type assertion (when you know the shape)
interface ApiUser { name: string; age: number }
const parsed = JSON.parse('{ "name": "Alice", "age": 30 }') as ApiUser
// Better fix: use a validation library (zod)
import { z } from 'zod'
const UserSchema = z.object({ name: z.string(), age: z.number() })
const validated = UserSchema.parse(JSON.parse('...')) // fully typed
// Pattern 3: Object.keys returns string[], not (keyof T)[]
const obj = { a: 1, b: 2, c: 3 }
// Object.keys(obj).forEach(k => obj[k]) // Error
// Fix: cast
(Object.keys(obj) as (keyof typeof obj)[]).forEach(k => {
console.log(obj[k]) // number
})
// Pattern 4: setTimeout/setInterval IDs differ between Node and browser
// Use ReturnType to be environment-agnostic
let timer: ReturnType<typeof setTimeout>
timer = setTimeout(() => {}, 1000)
clearTimeout(timer)Declaration Files (.d.ts) for JS Modules
If you have a JavaScript module that will not be converted for a while, you can place a sibling .d.ts file beside it to give it types without modifying the .js source.
// src/utils/legacyParser.d.ts
// Describes the shape of src/utils/legacyParser.js
export interface ParseOptions {
strict?: boolean
encoding?: 'utf-8' | 'ascii'
}
export interface ParseResult {
data: unknown
errors: string[]
}
export function parse(input: string, options?: ParseOptions): ParseResult
export function stringify(value: unknown): string
export const VERSION: string// Usage in a .ts file
import { parse, ParseOptions } from './legacyParser'
const options: ParseOptions = { strict: true }
const result = parse('some input', options)
// result is ParseResult — fully typed even though legacyParser.js is not convertedTracking Migration Progress
# Count remaining .js files find src -name "*.js" | wc -l # Count current TypeScript errors npx tsc --noEmit 2>&1 | grep "error TS" | wc -l # See which files have the most errors npx tsc --noEmit 2>&1 | grep "error TS" | sed 's/(.*//g' | sort | uniq -c | sort -rn | head -20
strict: true as the end state. The key insight is that partial TypeScript is dramatically better than no TypeScript — start the migration today.