TypeScriptMigrating from JavaScript

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
  1. Install TypeScript and create tsconfig.json with allowJs and lenient settings

  2. Run the TypeScript compiler in check-only mode to see the baseline error count

  3. Rename .js files to .ts one module at a time (start with leaf modules — those with no imports)

  4. Fix type errors in each converted file before moving to the next

  5. Enable stricter compiler options incrementally as the codebase improves

  6. Reach strict: true as the final goal

Note
You do not have to convert everything at once. A mixed JS/TS codebase is perfectly valid during migration. The allowJs option lets TypeScript compile both file types simultaneously.
Step 1 — Install TypeScript

Bash
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.

JSON
{
  "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

Bash
# 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
Tip
Run 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.

Bash
# 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
Note
Rename .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.

TS
// 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.

Bash
# 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/

TS
// 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 typed
When @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.

TS
// 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.

JS
// @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.

  1. strictNullChecks — fixes null/undefined bugs, highest value

  2. noImplicitAny — forces explicit types on all parameters

  3. strictFunctionTypes — correct variance for function parameters

  4. strictPropertyInitialization — class properties must be set in constructor

  5. noImplicitThis — forces explicit this parameter type

  6. strictBindCallApply — types bind/call/apply correctly

  7. alwaysStrict — emits "use strict" in output and enables strict parsing

  8. Set strict: true once all individual flags pass — it enables all of the above

JSON
// 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.

TS
// 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.

TS
// 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

TS
// 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 converted
Tracking Migration Progress

Bash
# 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
Success
A successful migration follows a clear arc: install TypeScript with lenient settings, convert leaf modules first, fix errors file by file, tighten compiler options incrementally, and reach strict: true as the end state. The key insight is that partial TypeScript is dramatically better than no TypeScript — start the migration today.