Skip to main content
Back to blog

Migrating a JavaScript project to TypeScript

·3 min readWeb Dev

I wrote a post back in 2022 about why TypeScript is worth it. This is the companion piece: how to actually migrate an existing JavaScript project without losing your mind.

The key insight is that you do not need to convert everything at once. TypeScript is designed for incremental adoption. You can have .ts and .js files coexist in the same project indefinitely.

Step 1: Add TypeScript to the project

pnpm add -D typescript @types/node
npx tsc --init

Edit tsconfig.json to enable gradual migration:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "strict": false,
    "allowJs": true,
    "checkJs": false,
    "outDir": "./dist",
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

The critical settings: allowJs: true lets TypeScript process JavaScript files. strict: false avoids a flood of errors on day one. checkJs: false means JavaScript files are not type-checked yet.

Step 2: Rename files one at a time

Start with a leaf file (one that does not import many other files). Rename it from .js to .ts. Fix any type errors that appear. Move to the next file.

# Start with utility files, then work up to more complex ones
mv src/utils/format-date.js src/utils/format-date.ts
mv src/utils/calculate.js src/utils/calculate.ts

Add types to function parameters and return values:

// Before (JavaScript)
function formatDate(date) {
  return new Intl.DateTimeFormat("en-US").format(date);
}
 
// After (TypeScript)
function formatDate(date: Date): string {
  return new Intl.DateTimeFormat("en-US").format(date);
}

Step 3: Add types for external dependencies

Most popular npm packages have type definitions:

pnpm add -D @types/express @types/lodash

For packages without types, create a declaration file:

// src/types/untyped-package.d.ts
declare module "untyped-package" {
  export function doSomething(input: string): void;
}

Step 4: Enable strict mode gradually

Once most files are converted, start enabling strict checks one at a time in tsconfig.json:

{
  "compilerOptions": {
    "strict": false,
    "noImplicitAny": true,
    "strictNullChecks": true
  }
}

noImplicitAny forces you to type function parameters (the highest-value check). strictNullChecks forces you to handle null and undefined (catches the most runtime errors). Enable these first, fix the errors, then turn on full strict mode.

Step 5: Remove allowJs

Once every file is a .ts or .tsx file, set allowJs: false. The migration is complete.

Tips from doing this multiple times

Do not convert everything in one PR. Convert 5-10 files at a time. Each PR should be reviewable and should not introduce regressions.

Start with utility files and data types. These have the clearest type signatures and fewest dependencies. Work outward from there.

Create shared type definitions early. If your app has a User, Post, or Product type, define it in a types.ts file early and import it everywhere. This establishes the type vocabulary for the project.

Use any as a temporary escape hatch. If a type is complex and blocking your progress, use any and add a // TODO: type properly comment. Come back to it later. The goal is forward progress, not perfection on day one.

Run the build in CI. Add tsc --noEmit to your CI pipeline as soon as you start the migration. This prevents new JavaScript files from being added and catches type errors before merge.

Sources

Enjoying the blog? Subscribe via RSS to get new posts in your reader.

Subscribe via RSS