I remember the exact moment I decided I was done with pure JavaScript. It was 2:00 AM on a Friday, and a critical production service had just crashed because of the infamous TypeError: Cannot read properties of undefined (reading 'map'). A deeply nested API response had changed slightly, and our vanilla JavaScript codebase blindly trusted the data. No compilation errors, no IDE warnings—just a silent ticking time bomb that detonated when our users needed the system most.
If you are reading this, you have probably hit your own breaking point. You are tired of guessing what shape an object has, tired of writing defensive if (typeof x === 'undefined') checks everywhere, and tired of brittle refactoring. You know you need to move to TypeScript. But looking at a massive, undocumented, dynamically typed legacy codebase makes a migration feel like trying to change the tires on a car while driving 80 miles per hour down the highway.
I have led multiple migrations across massive codebases—ranging from monolithic React frontends to sprawling Node.js microservices. The secret to success is never stopping the world. You cannot tell your product managers that feature development is paused for three months while you rewrite everything. Instead, you need a pragmatic, incremental approach. If you want to migrate javascript to typescript step by step, you have to do it systematically, file by file, without breaking your existing build.
Let’s dive into the exact blueprint I use to transform chaotic JavaScript projects into robust, type-safe TypeScript codebases.
Why the “Big Bang” TypeScript Migration Fails
The most common mistake teams make when transitioning from JavaScript to TypeScript is the “Big Bang” rewrite. A well-meaning developer creates a branch named feat/typescript-migration, runs a script to rename every .js file to .ts, and is immediately greeted by 4,302 TypeScript Errors. They spend the next four weeks playing whack-a-mole with the TypeScript Compiler, throwing any at everything just to get the project to build.
By the time they open the pull request, the branch is thousands of commits behind main. The merge conflicts are catastrophic. The team’s velocity grinds to a halt, and the resulting code is just JavaScript with any sprinkled on top—offering zero actual type safety. You get all the overhead of TypeScript Build tools with none of the benefits of TypeScript Type Inference or TypeScript Best Practices.
The alternative is the incremental migration. TypeScript was designed specifically to interoperate with JavaScript. You can have a project that is 99% JavaScript and 1% TypeScript, and the TypeScript Compiler will happily process it. We will leverage this interoperability to migrate javascript to typescript step by step, starting with configuration, moving to foundational utility files, and eventually typing our complex React components and Node.js controllers.
Phase 1: Preparing Your Build Pipeline and TypeScript TSConfig
Before we touch a single line of application code, we need to teach our tooling how to understand TypeScript. This means updating our bundler (whether that is TypeScript Webpack, TypeScript Vite, or a Node environment) and creating our foundational TypeScript Configuration.
First, install the core dependencies. I highly recommend locking to the latest stable minor version (e.g., TypeScript 5.4) to ensure you have the latest TypeScript Utility Types and performance improvements.
npm install --save-dev typescript @types/node
Next, we need to generate a tsconfig.json file. You can run npx tsc --init, but it spits out a massive file full of commented-out options. Instead, create a tsconfig.json at the root of your project and use this battle-tested configuration designed specifically for incremental migrations:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "node",
// The most important setting for migration:
"allowJs": true,
// Start loose, we will tighten this later:
"strict": false,
"noImplicitAny": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react-jsx", // Remove if not a React project
"outDir": "./dist",
"baseUrl": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
}
Let me break down why this specific configuration is critical:
allowJs: true: This is the magic bullet. It tells the TypeScript Compiler to accept.jsand.jsxfiles alongside.tsand.tsxfiles. Without this, an incremental migration is impossible.strict: false: Do not turn on TypeScript Strict Mode yet. If you flip this on day one, you will be overwhelmed with errors. We will incrementally crank up the strictness in Phase 5.skipLibCheck: true: This speeds up your TypeScript Build dramatically by skipping type checking for third-party libraries innode_modules.
Phase 2: Aligning TypeScript ESLint and Prettier
Tooling alignment is critical. If your linter doesn’t understand TypeScript syntax, your CI pipeline will fail the moment you write your first TypeScript Interface. We need to swap out the standard JavaScript ESLint parser for the TypeScript ESLint parser.
Install the necessary ESLint packages:
npm install --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin
Update your .eslintrc.js (or equivalent config) to use the new parser. Notice how we use the overrides array. This is a crucial TypeScript Tips strategy: we only want the strict TypeScript linting rules to apply to .ts and .tsx files, leaving our legacy .js files alone so we don’t break our existing CI checks.
module.exports = {
root: true,
env: {
browser: true,
node: true,
es2022: true,
},
extends: [
'eslint:recommended',
// Your existing plugins here (e.g., plugin:react/recommended)
],
overrides: [
{
// Only apply TypeScript rules to TypeScript files
files: ['**/*.ts', '**/*.tsx'],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: [
'plugin:@typescript-eslint/recommended',
],
rules: {
// Temporarily allow 'any' while migrating
'@typescript-eslint/no-explicit-any': 'warn',
}
}
]
};
If you are using TypeScript Jest TypeScript for your unit tests, you will also need to install ts-jest or configure Babel to strip TypeScript types during test execution. My preference for modern TypeScript Development is using ts-jest or switching to Vite/Vitest for out-of-the-box TypeScript Unit Tests support.

Phase 3: The Bottom-Up Migration Strategy (Your First .ts File)
Now that the tooling is in place, it is time to write some TypeScript. The golden rule of how to migrate javascript to typescript step by step is to use a bottom-up approach.
Do not start by renaming your main entry point (like App.js or server.js). The entry point imports everything, meaning it relies on the types of the entire application. Instead, start at the “leaves” of your dependency tree. Look for files that import nothing, but are imported by many other files. These are usually utility functions, helpers, or constants.
Let’s take a typical JavaScript utility file, src/utils/formatters.js:
// src/utils/formatters.js
export const formatCurrency = (amount, currencyCode) => {
if (!amount) return '$0.00';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode || 'USD',
}).format(amount);
};
export const getUserDisplayName = (user) => {
if (!user) return 'Guest';
return user.firstName && user.lastName
? ${user.firstName} ${user.lastName}
: user.username;
};
Rename this file to src/utils/formatters.ts. As soon as you do, your IDE will likely highlight amount, currencyCode, and user with an implicit any warning. Let’s apply TypeScript Types and TypeScript Interfaces to lock this down.
// src/utils/formatters.ts
// 1. Define a clear interface for our data structure
export interface User {
id: string;
username: string;
firstName?: string; // Optional property
lastName?: string; // Optional property
}
// 2. Apply TypeScript Arrow Functions typing
export const formatCurrency = (amount: number | null | undefined, currencyCode: string = 'USD'): string => {
if (!amount) return '$0.00';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
}).format(amount);
};
// 3. Leverage the User interface
export const getUserDisplayName = (user: User | null): string => {
if (!user) return 'Guest';
return user.firstName && user.lastName
? ${user.firstName} ${user.lastName}
: user.username;
};
Look at what we accomplished here. By using TypeScript Union Types (number | null | undefined), we explicitly document what inputs are valid. By creating the User interface, we have created a contract. Any legacy JavaScript file that imports getUserDisplayName will now get rich IDE autocomplete, even if the calling file is still .js. This is the power of a gradual migration: typing the bottom layers improves the developer experience for the entire codebase.
Phase 4: Typing Complex Layers (TypeScript React and Node.js)
Once your utilities and API clients are typed, you move up the dependency tree to your UI components or backend controllers. This is where you leverage TypeScript Advanced features.
Migrating a React Component
In a React codebase, moving from PropTypes to TypeScript Interfaces is incredibly satisfying. Let’s migrate a functional component. Rename your .jsx file to .tsx.
// src/components/UserProfile.tsx
import React, { useState } from 'react';
import { User } from '../utils/formatters'; // Importing our typed interface!
// Define props explicitly
interface UserProfileProps {
user: User;
onLogout: (userId: string) => void;
isLoading?: boolean;
}
export const UserProfile = ({ user, onLogout, isLoading = false }: UserProfileProps) => {
// TypeScript Type Inference automatically knows isEditing is a boolean
const [isEditing, setIsEditing] = useState(false);
if (isLoading) {
return <div>Loading...</div>;
}
return (
<div className="profile-card">
<h2>{user.username}</h2>
<button onClick={() => onLogout(user.id)}>
Logout
</button>
</div>
);
};
Notice that we do not use React.FC here. In modern TypeScript React (especially post React 18), the community consensus and TypeScript Best Practices recommend typing the props object directly rather than using React.FC, as it handles children more explicitly and avoids generic component constraints.
Migrating a Node.js Express Controller
If you are working on a backend, TypeScript Node.js migrations follow the same bottom-up pattern. When dealing with TypeScript Express, you will want to install the community types: npm install --save-dev @types/express.
// src/controllers/userController.ts
import { Request, Response } from 'express';
import { User } from '../models/user';
// Type the Request parameters, query, and body using TypeScript Generics
interface UpdateUserBody {
firstName: string;
lastName: string;
}
export const updateUser = async (
req: Request<{ id: string }, {}, UpdateUserBody>,
res: Response
): Promise<void> => {
try {
const userId = req.params.id; // Strongly typed as string
const { firstName, lastName } = req.body; // Strongly typed from UpdateUserBody
// Imagine a typed database call here
const updatedUser = await db.users.update(userId, { firstName, lastName });
res.status(200).json(updatedUser);
} catch (error) {
// In TypeScript, caught errors are of type 'unknown' by default
if (error instanceof Error) {
res.status(500).json({ message: error.message });
} else {
res.status(500).json({ message: 'An unknown error occurred' });
}
}
};
Here we see TypeScript Generics in action. By passing type arguments to the Express Request type, we get strict autocomplete on req.params and req.body. We also see TypeScript Type Guards in the catch block, safely checking if the error is an instance of Error before accessing error.message.
Phase 5: Cranking Up the Strictness (TypeScript Strict Mode)
Fast forward a few months. You have successfully renamed 80% of your files to .ts and .tsx. Your team is comfortable with TypeScript Interfaces, TypeScript Classes, and TypeScript Enums. Now, it is time to pay off the technical debt we intentionally incurred in Phase 1.
Open your tsconfig.json and change "strict": false to "strict": true". This single boolean flag enables a suite of strict checks, including noImplicitAny, strictNullChecks, and strictBindCallApply.
When you do this, your terminal will likely light up red. Do not panic. This is TypeScript doing its job. You will primarily see two types of TypeScript Errors:

- Implicit
anyerrors: You forgot to type a function parameter, and TypeScript can’t infer it. Fix this by explicitly defining the type or interface. - Object is possibly ‘null’ or ‘undefined’: This is the exact error that brought down my production environment years ago. TypeScript is forcing you to handle the edge cases.
To fix the nullability errors, use TypeScript Type Guards and Optional Chaining. If you absolutely know a value exists (and you are willing to bet your job on it), you can use the non-null assertion operator (!), but I strongly advise against it. Instead, handle the state properly:
// BAD: TypeScript Type Assertions bypassing safety
const user = document.getElementById('user-data')!;
const name = user.getAttribute('data-name'); // If element doesn't exist, this crashes at runtime.
// GOOD: TypeScript Type Guards
const user = document.getElementById('user-data');
if (user) {
const name = user.getAttribute('data-name');
// Safely use name
}
TypeScript Best Practices for a Seamless Migration
As you migrate javascript to typescript step by step, keep these hard-won TypeScript Best Practices in mind:
1. Stop using any as an escape hatch.
When developers get frustrated with TypeScript Errors, they often reach for any to silence the compiler. Every time you use any, you are turning off TypeScript for that variable. If you truly don’t know what a value will be (like an API payload), use unknown. Unlike any, unknown forces you to write a TypeScript Type Guard to validate the data shape before you can use it.
2. Let TypeScript Type Inference do the heavy lifting.
You do not need to type every single variable. If you write const count = 10;, TypeScript knows it is a number. Typing it as const count: number = 10; is redundant visual noise. Focus your typing efforts on function boundaries (parameters and return types) and let inference handle the internals.
3. Use TypeScript Utility Types to reduce boilerplate.
Instead of duplicating interfaces, use built-in utility types. If you have an interface for a database model, but your update endpoint only requires a subset of those fields, use Partial<T>. If you need to omit sensitive data like passwords, use Omit<T, 'password'>. Master TypeScript Utility Types like Pick, Omit, Record, and ReturnType to keep your code DRY.
4. Automate your types with third-party libraries.
If you are using a library like Lodash, React Router, or Express, always check for DefinitelyTyped packages. Running npm install --save-dev @types/lodash instantly gives you perfect type definitions for libraries that are written in pure JavaScript.
FAQ: Migrate JavaScript to TypeScript Step by Step
Should I migrate my entire JavaScript project to TypeScript at once?
Absolutely not. A “Big Bang” migration stops feature development, causes massive merge conflicts, and overwhelms the team with errors. Always use an incremental approach by enabling allowJs: true in your tsconfig.json and migrating file by file, starting from foundational utility files up to complex UI components.

How do I handle third-party libraries without TypeScript types?
First, check if an official @types/packagename exists on npm. If no community types are available, you can create a custom declaration file (e.g., global.d.ts) in your project and write declare module 'packagename';. This tells the TypeScript Compiler to treat the library as any, allowing your build to pass while you manually type the specific functions you use.
Does migrating to TypeScript affect application performance?
TypeScript does not affect runtime performance because it is completely stripped away during the build process—your browser or Node.js environment still runs pure JavaScript. However, it can slightly slow down your build times (TypeScript Build step). Utilizing modern bundlers like TypeScript Vite or using ts-node with swc can mitigate these compile-time delays.
Can I run JavaScript and TypeScript files together?
Yes, and this is the core of a successful migration strategy. By configuring your tooling (like Webpack, Vite, or ESLint) and setting allowJs: true in your TypeScript configuration, the compiler will seamlessly process both .js and .ts files in the same project, allowing them to import each other without issue.
The Final Commit: Wrapping Up Your TypeScript Migration
Successfully completing a TypeScript migration is a marathon, not a sprint. It might take weeks, months, or even a year depending on the size of your codebase. But the return on investment is staggering. The moment you auto-complete an intricately nested API response, or the moment the compiler catches a typo that would have caused a production outage, you will understand why the JavaScript ecosystem has overwhelmingly embraced TypeScript.
To migrate javascript to typescript step by step without breaking things, remember the core tenets: configure your build to allow JS, align your linting tools, start at the bottom of your dependency tree with pure functions, and slowly turn the dial on strictness. Don’t let perfection be the enemy of progress. A codebase that is 50% typed is significantly safer than one that is 0% typed. Take it one file, one interface, and one function at a time. Happy typing.
