Migrate JavaScript to TypeScript Step by Step

A practical step-by-step guide to migrating a JavaScript project to TypeScript. Start with compiler setup, add types gradually, and tighten checks over time.

7 min read

You migrate JavaScript to TypeScript by adding the compiler alongside your existing code, configuring it to accept both JavaScript and TypeScript files, and converting files one at a time at your own pace. This does not mean a rewrite. This guide walks through every step of a JavaScript to TypeScript migration.

JavaScript to TypeScript migration flow

The process moves from lenient to strict. You add safety gradually instead of trying to fix everything at once.

Step 1: Install TypeScript

Every migration starts by adding the TypeScript compiler to the project as a dev dependency, so the rest of the steps in this guide have a compiler to run against. Install the compiler and the Node.js type definitions:

bashbash
npm install --save-dev typescript
npm install --save-dev @types/node

If your project is browser-only, skip the Node type definitions package. The DOM types are built into TypeScript already, and adding Node type definitions to a browser project can cause naming conflicts between Node globals and browser globals.

Step 2: Create a Lenient tsconfig

Run the compiler's init command to generate a base file, then replace it with a migration-friendly configuration. Start with the core compiler target and the settings that let JavaScript files compile alongside TypeScript ones:

jsonjson
{
  "compilerOptions": {
    "target": "es2022",
    "module": "commonjs",
    "outDir": "./dist",
    "allowJs": true,
    "checkJs": false,
    "strict": false
  },
  "include": ["src/**/*"]
}

Add the remaining options that keep type checking lenient during the initial pass and smooth out interop with existing CommonJS packages, so the first compile does not overwhelm you with unrelated errors:

jsonjson
{
  "compilerOptions": {
    "noImplicitAny": false,
    "strictNullChecks": false,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

Key decisions in this config:

  • allowJs lets JavaScript files compile alongside TypeScript files.
  • checkJs stays off at first, to avoid flooding you with errors from existing JavaScript files.
  • strict stays off at first, so type checking starts as lenient as possible.
  • outDir keeps compiled output separate from source files.

If your JavaScript is not in a source folder, move it there first. See Add TypeScript to a JavaScript Project for the initial setup.

Step 3: Rename One File at a Time

Convert files starting from the edges of your dependency graph. Leaf modules (files that nothing else imports) are the safest starting points:

texttext
src/
  utils.js        <-- rename this first (no internal dependencies)
  config.js       <-- then this
  models/user.js  <-- then this
  index.js        <-- rename this last (imports everything)

This order matters because a leaf file has no local imports to break, so renaming it cannot cascade errors into other files. Rename the file and run the compiler:

bashbash
mv src/utils.js src/utils.ts
npx tsc

Expect errors. That is the point: each error is a place where TypeScript found something worth checking.

Step 4: Fix Errors in Small Batches

Focus on one file at a time instead of trying to clear every error across the whole project at once. The sections below cover the most common errors you will hit right after a rename, along with the fix for each.

The most frequent error in untyped JavaScript is an implicit any on a function parameter, because TypeScript cannot infer a type from a plain function signature. In the example below, the name parameter has no annotation, so the compiler cannot check how it is used:

typescripttypescript
// Before (error)
function formatName(name) {
  return name.toUpperCase();
}

The fix is a type annotation on the parameter. Once the parameter is declared as a string, the compiler can verify that calling the uppercase method on it is safe:

typescripttypescript
// After (fixed)
function formatName(name: string) {
  return name.toUpperCase();
}

If you are not sure of the real type yet, annotate the parameter as unknown instead of guessing. Narrow it with a runtime check before you use it, so the compiler still enforces safety while you figure out the actual shape:

typescripttypescript
function process(data: unknown) {
  if (typeof data === "string") {
    console.log(data.toUpperCase());
  }
}

A second common error appears when you import a package and TypeScript reports that it cannot find the module. This happens because the compiler has no type declarations for that package, not because the package is missing. Install the community-maintained types for it:

bashbash
npm install --save-dev @types/lodash

Most popular packages have types on DefinitelyTyped, so this command is usually enough to resolve the error. If a package does not have published types, see Type Untyped Packages in TypeScript.

A third common pattern is dynamic property assignment, where JavaScript code adds properties to an object after it is created. TypeScript infers the type of the object from its initial empty literal, so it rejects any property that was not part of that shape:

typescripttypescript
// Before (error in TypeScript)
const config = {};
config.port = 3000;
config.host = "localhost";

Fix this by declaring the full shape upfront, either inline or with an interface, so TypeScript knows about every property before you assign to it:

typescripttypescript
interface Config {
  port: number;
  host: string;
}
 
const config: Config = {
  port: 3000,
  host: "localhost",
};

A fourth pattern is code that reads the legacy arguments object inside a function. TypeScript types it loosely and it does not work in arrow functions, so migrations usually replace it with rest parameters:

typescripttypescript
// Before
function logAll() {
  for (let i = 0; i < arguments.length; i++) {
    console.log(arguments[i]);
  }
}

Rest parameters give you a real, typed array instead of the arguments object, so you get autocompletion and array methods like map or filter for free:

typescripttypescript
// After
function logAll(...args: unknown[]) {
  for (const arg of args) {
    console.log(arg);
  }
}

The last common pattern is a CommonJS require call. Convert it to an ES module import so the file matches modern TypeScript conventions:

typescripttypescript
// Before
const fs = require("fs");
const { join } = require("path");
 
// After
import * as fs from "fs";
import { join } from "path";

If your project uses CommonJS module output in tsconfig, TypeScript compiles the import statement back to a require call in the output, so this change is safe.

Step 5: Handle Errors You Cannot Fix Yet

Some errors are hard to fix immediately, especially in deeply nested legacy code where you do not yet know the real shape of the data. Use any as a temporary escape hatch so the file compiles while you plan a proper fix:

typescripttypescript
function legacyHandler(data: any) {
  return data.something.deeply.nested;
}

The any type opts out of type checking entirely. It is fine as a temporary tool during migration, but remove it once you understand the shape. Step 6 introduces a check that flags remaining any usage so it does not get forgotten.

Sometimes you want to suppress a single error line instead of typing an entire parameter as any. Add a @ts-ignore comment on the line directly above the error:

typescripttypescript
// @ts-ignore
const result = legacyLibrary.doMagic(params);

This suppresses the error on the next line. Use it sparingly. It is better than a broad any type for a single line because it does not cascade through the rest of the code.

Step 6: Enable noImplicitAny

Once all files compile, enable the first strict check:

jsonjson
{
  "compilerOptions": {
    "noImplicitAny": true
  }
}

This flags every place where TypeScript cannot infer a type and would silently fall back to any. Fix each one by adding an explicit type:

typescripttypescript
// Error with noImplicitAny
function handle(event) {
  return event.target.value;
}
 
// Fixed
function handle(event: Event) {
  return (event.target as HTMLInputElement).value;
}

This step surfaces many hidden assumptions in your code. Work through them methodically.

Step 7: Enable strictNullChecks

Next, turn on null safety:

jsonjson
{
  "compilerOptions": {
    "strictNullChecks": true
  }
}

This makes null and undefined distinct types instead of letting them silently flow through as if they were any other value. Any value that might be missing must be checked before use. In the example below, getElementById can return null when no element matches the id, so TypeScript rejects direct property access on the result:

typescripttypescript
// Error with strictNullChecks
function getElement(id: string) {
  return document.getElementById(id).innerHTML;
}

The fix adds an explicit check for null before accessing the property, so the compiler can confirm the value is safe to use on every path:

typescripttypescript
// Fixed
function getElement(id: string) {
  const el = document.getElementById(id);
  if (el === null) {
    throw new Error(`Element #${id} not found`);
  }
  return el.innerHTML;
}

strictNullChecks catches the most common runtime error in JavaScript: accessing a property on null or undefined. This single setting prevents more bugs than any other.

Step 8: Enable Full Strict Mode

When the codebase is clean under noImplicitAny and strictNullChecks, enable full strict mode:

jsonjson
{
  "compilerOptions": {
    "strict": true
  }
}

Setting strict to true bundles together all strict checks:

FlagWhat it catches
strictNullChecksNull and undefined access
noImplicitAnyUnannotated parameters and variables
strictFunctionTypesUnsound function type assignments
strictBindCallApplyIncorrect bind, call, and apply usage
strictPropertyInitializationUninitialized class properties
noImplicitThisImplicit any for the this keyword
alwaysStrictEmits "use strict" in output
useUnknownInCatchVariablesCatch clause variables are typed unknown

At this point, your project is fully type-safe. New code benefits from strict checking, and the compiler enforces the same rules across the entire codebase.

What to Do With Files You Cannot Convert

Some files may be impractical to convert right away: generated code, third-party scripts bundled in your repo, or legacy modules that are scheduled for a rewrite.

Keep allowJs enabled in tsconfig and leave those files as JavaScript. They continue to compile and work. Add a @ts-nocheck comment at the top if they produce errors you do not want to see.

For third-party packages without types, create a minimal declaration file:

typescripttypescript
// src/types/legacy-lib.d.ts
declare module "legacy-lib" {
  export function doThing(input: string): number;
}

This gives you type safety at the boundary without touching the library itself.

Test After Each Batch

TypeScript types are compile-time only. Adding annotations does not change runtime behavior by itself.

Fixing type errors sometimes means adding null checks, restructuring objects, or changing function signatures. Those code changes can introduce bugs, so testing still matters.

After each batch of converted files:

bashbash
npm test

If you do not have tests, run the application and verify key features manually. The compiler guarantees type safety; it does not guarantee that your logic is correct.

Migration Priorities

Convert files in this order for the smoothest experience:

PriorityFile typeWhy
1Utility functionsNo dependencies, quick wins
2Data models and typesDefines shapes the rest of the codebase uses
3Service and API layersCore business logic
4Configuration filesSmall surface area, high impact
5Entry points and routersDepends on everything above
6Test filesBenefit from types for test data

Each converted file improves editor autocompletion and catches more errors at compile time, making the next file easier to convert.

Rune AI

Rune AI

Key Insights

  • Install TypeScript and @types/node, then create a lenient tsconfig with allowJs.
  • Rename files from .js to .ts one at a time, starting with leaf modules.
  • Fix the most common errors first: implicit any, missing modules, and dynamic property access.
  • Enable strict checks gradually: noImplicitAny first, then strictNullChecks, then full strict.
  • Test after each batch of changes to catch behavior differences.
  • Keep allowJs enabled for files you are not ready to convert yet.
RunePowered by Rune AI

Frequently Asked Questions

How long does a JavaScript-to-TypeScript migration take?

It depends on the codebase size. A small project can be fully converted in a day. A large codebase may take weeks or months of incremental work. The key is that TypeScript supports partial migration, so you do not need to stop feature work.

Should I enable strict mode from the start?

No. Start with strict: false and enable individual strict checks one at a time after the initial conversion. Enabling full strict mode on an untyped codebase can produce thousands of errors.

What is the biggest risk during migration?

The biggest risk is accidentally changing runtime behavior. TypeScript types are compile-time only, so they do not affect how the code runs. But changes you make to fix type errors (like adding null checks) can change behavior. Test thoroughly after each batch of changes.

Conclusion

Migrating JavaScript to TypeScript is a gradual process: install the compiler, configure allowJs, rename files one at a time, fix errors in small batches, and tighten strict settings as your codebase improves. You control the pace. Every converted file gives you better tooling and fewer bugs.