TypeScript Migration Guide: Convert JS Projects 2026
AI & Development

TypeScript Migration Guide: Convert JS Projects 2026

Complete guide to migrating JavaScript projects to TypeScript. Learn the step-by-step process, avoid common mistakes, and improve code quality.

Jan 27, 2026
13 min read
TypeScript Migration Guide: Convert JS Projects 2026

Migrating from JavaScript to TypeScript can feel overwhelming, but it doesn't have to be. After helping dozens of teams make this transition, I've learned what works and what doesn't. This guide will walk you through the entire process, step by step.

Related reading: Check out our guides on AI coding assistants and remote team management for more development insights.

Why Migrate to TypeScript in 2026?#

The Current State of TypeScript#

TypeScript has become the de facto standard for large-scale JavaScript applications. Here's why:

Adoption statistics:

  • 78% of developers use or want to use TypeScript (Stack Overflow 2025)
  • 95% of new npm packages include TypeScript definitions
  • Major frameworks (React, Vue, Angular) have first-class TypeScript support
  • Companies like Airbnb, Slack, and Microsoft rely on TypeScript

Real benefits you'll see:

  • Catch bugs before runtime: Type errors caught during development, not production
  • Better IDE support: Autocomplete, refactoring, and navigation that actually works
  • Easier refactoring: Change code with confidence, knowing the compiler has your back
  • Self-documenting code: Types serve as inline documentation
  • Improved team collaboration: Clear contracts between different parts of your codebase

When NOT to Migrate#

TypeScript isn't always the answer. Skip migration if:

  • Your project is under 1,000 lines of code
  • You're building a quick prototype or MVP
  • Your team has zero TypeScript experience and tight deadlines
  • The project is in maintenance mode with no active development
  • You're working on simple scripts or build tools

Pre-Migration Checklist#

Before writing a single line of TypeScript, prepare your project:

1. Assess Your Codebase#

Run these checks:

# Count lines of code
npx cloc . --exclude-dir=node_modules

# Check dependencies
npm outdated

# Analyze bundle size
npx webpack-bundle-analyzer

Questions to answer:

  • How many files need migration? (Start with <50 for first attempt)
  • Are dependencies TypeScript-compatible?
  • What's your test coverage? (Aim for >60% before migrating)
  • Do you have a CI/CD pipeline? (Essential for catching type errors)

2. Update Your Dependencies#

Critical updates:

# Update to latest stable versions
npm update

# Check for TypeScript support
npm info [package-name] types

# Install type definitions
npm install --save-dev @types/react @types/node

Common packages that need type definitions:

  • @types/react - React types
  • @types/react-dom - React DOM types
  • @types/node - Node.js types
  • @types/jest - Jest testing types
  • @types/express - Express.js types

3. Set Up Your Development Environment#

Install TypeScript:

npm install --save-dev typescript @types/node
npx tsc --init

Essential VS Code extensions:

  • ESLint (dbaeumer.vscode-eslint)
  • TypeScript Error Translator (mattpocock.ts-error-translator)
  • Pretty TypeScript Errors (yoavbls.pretty-ts-errors)

Step-by-Step Migration Process#

Step 1: Configure TypeScript#

Create a tsconfig.json that allows gradual migration:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "lib": ["ES2020", "DOM"],
    "jsx": "react-jsx",
    "strict": false,
    "allowJs": true,
    "checkJs": false,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "incremental": true,
    "moduleResolution": "node"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "build", "dist"]
}

Key settings explained:

  • allowJs: true - Allows .js files alongside .ts files
  • strict: false - Start lenient, tighten later
  • checkJs: false - Don't type-check JavaScript files yet
  • noEmit: true - Use TypeScript for type-checking only

Step 2: Rename Files Strategically#

Start with these file types (in order):

  1. Utility functions and helpers
  2. Constants and configuration files
  3. Type definitions and interfaces
  4. React components (leaf nodes first)
  5. API clients and services
  6. Main application files

Renaming strategy:

# Rename one file at a time
mv src/utils/helpers.js src/utils/helpers.ts

# For React components
mv src/components/Button.jsx src/components/Button.tsx

Pro tip: Use git to track changes:

git mv src/utils/helpers.js src/utils/helpers.ts

Step 3: Add Basic Types#

Start with the easiest wins:

Function parameters and return types:

// Before (JavaScript)
function calculateTotal(items) {
  return items.reduce((sum, item) => sum + item.price, 0);
}

// After (TypeScript)
function calculateTotal(items: Array<{ price: number }>): number {
  return items.reduce((sum, item) => sum + item.price, 0);
}

Object shapes with interfaces:

// Define your data structures
interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

interface Product {
  id: string;
  name: string;
  price: number;
  inStock: boolean;
}

// Use them in functions
function getUserById(id: string): User | null {
  // Implementation
}

React component props:

// Before
export function Button({ label, onClick, disabled }) {
  return <button onClick={onClick} disabled={disabled}>{label}</button>;
}

// After
interface ButtonProps {
  label: string;
  onClick: () => void;
  disabled?: boolean;
}

export function Button({ label, onClick, disabled = false }: ButtonProps) {
  return <button onClick={onClick} disabled={disabled}>{label}</button>;
}

Step 4: Handle Common Patterns#

API responses:

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

interface User {
  id: string;
  name: string;
  email: string;
}

async function fetchUser(id: string): Promise<ApiResponse<User>> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

Event handlers:

// React events
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
  event.preventDefault();
  // Handle click
}

function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
  const value = event.target.value;
  // Handle change
}

// DOM events
function handleKeyPress(event: KeyboardEvent) {
  if (event.key === 'Enter') {
    // Handle enter key
  }
}

State management:

// React useState
const [count, setCount] = useState<number>(0);
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<Product[]>([]);

// Redux
interface RootState {
  user: User | null;
  products: Product[];
  cart: CartItem[];
}

function selectUser(state: RootState): User | null {
  return state.user;
}

Step 5: Deal with Third-Party Libraries#

Check for type definitions:

# Search for types
npm search @types/[package-name]

# Install types
npm install --save-dev @types/lodash

If types don't exist, create declarations:

// src/types/custom.d.ts
declare module 'untyped-package' {
  export function someFunction(param: string): void;
}

// Or use any as escape hatch
declare module 'legacy-package' {
  const content: any;
  export default content;
}

Step 6: Enable Strict Mode Gradually#

Tighten your tsconfig.json over time:

{
  "compilerOptions": {
    // Phase 1: Basic strictness
    "noImplicitAny": true,
    "strictNullChecks": false,
    
    // Phase 2: More strictness
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    
    // Phase 3: Full strict mode
    "strict": true
  }
}

Fix errors incrementally:

// noImplicitAny errors
function process(data: any) { // Add explicit type
  // ...
}

// strictNullChecks errors
function getUser(id: string): User | null { // Add null to return type
  return users.find(u => u.id === id) ?? null;
}

// Use optional chaining
const userName = user?.name ?? 'Guest';

Common Migration Pitfalls#

1. The "Any" Escape Hatch Trap#

Problem: Using any everywhere defeats the purpose

Bad:

function processData(data: any): any {
  return data.map((item: any) => item.value);
}

Good:

interface DataItem {
  value: number;
  label: string;
}

function processData(data: DataItem[]): number[] {
  return data.map(item => item.value);
}

2. Over-Engineering Types#

Problem: Creating overly complex type hierarchies

Bad:

type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

type ComplexType<T, U extends keyof T> = Omit<T, U> & Partial<Pick<T, U>>;

Good: Start simple, add complexity only when needed

interface User {
  id: string;
  name: string;
  email: string;
}

type PartialUser = Partial<User>; // Built-in utility type

3. Ignoring Type Errors#

Problem: Using @ts-ignore or @ts-expect-error too liberally

Bad:

// @ts-ignore
const result = someComplexOperation();

Good: Fix the underlying issue

const result = someComplexOperation() as ExpectedType;
// Or better: fix the function signature

4. Not Using Utility Types#

TypeScript provides powerful built-in utilities:

interface User {
  id: string;
  name: string;
  email: string;
  password: string;
}

// Pick specific properties
type PublicUser = Pick<User, 'id' | 'name' | 'email'>;

// Omit properties
type UserWithoutPassword = Omit<User, 'password'>;

// Make all properties optional
type PartialUser = Partial<User>;

// Make all properties required
type RequiredUser = Required<PartialUser>;

// Make all properties readonly
type ImmutableUser = Readonly<User>;

Migration Strategies by Project Size#

Small Projects (<10,000 lines)#

Timeline: 1-2 weeks

Strategy: Big bang migration

  1. Set up TypeScript configuration
  2. Rename all files in one go
  3. Fix type errors file by file
  4. Enable strict mode

Pros: Fast, clean cutover Cons: Risky, blocks other work

Medium Projects (10,000-50,000 lines)#

Timeline: 1-2 months

Strategy: Module-by-module migration

  1. Identify independent modules
  2. Migrate one module per week
  3. Keep JavaScript and TypeScript coexisting
  4. Gradually enable strict mode

Pros: Manageable, less risky Cons: Longer timeline, mixed codebase

Large Projects (>50,000 lines)#

Timeline: 3-6 months

Strategy: Incremental migration

  1. New code in TypeScript only
  2. Migrate on touch (when editing files)
  3. Dedicate 20% of sprint to migration
  4. Use automated tools (ts-migrate, etc.)

Pros: Minimal disruption, continuous progress Cons: Long timeline, requires discipline

Automated Migration Tools#

ts-migrate (Airbnb's Tool)#

Install and run:

npx ts-migrate-full <project-directory>

What it does:

  • Renames .js to .ts files
  • Adds any types everywhere
  • Handles imports and exports
  • Creates basic type definitions

Pros: Fast initial conversion Cons: Generates lots of any types

TypeScript Language Service#

Use in VS Code:

  1. Open Command Palette (Cmd/Ctrl + Shift + P)
  2. "TypeScript: Rename File"
  3. Automatically updates imports

Codemod Scripts#

Create custom transformations:

npm install -g jscodeshift

Example codemod:

// transform.js
module.exports = function(fileInfo, api) {
  const j = api.jscodeshift;
  const root = j(fileInfo.source);
  
  // Find all function declarations
  root.find(j.FunctionDeclaration)
    .forEach(path => {
      // Add type annotations
    });
  
  return root.toSource();
};

Testing During Migration#

Update Your Test Setup#

Jest configuration:

// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
  transform: {
    '^.+\\.tsx?$': 'ts-jest',
  },
  testMatch: ['**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)'],
};

Type your tests:

import { render, screen } from '@testing-library/react';
import { Button } from './Button';

describe('Button', () => {
  it('renders with label', () => {
    render(<Button label="Click me" onClick={() => {}} />);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });
  
  it('calls onClick when clicked', () => {
    const handleClick = jest.fn();
    render(<Button label="Click" onClick={handleClick} />);
    screen.getByText('Click').click();
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
});

Type-Check in CI/CD#

Add to your pipeline:

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  type-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm ci
      - run: npm run type-check
      
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm ci
      - run: npm test

Package.json scripts:

{
  "scripts": {
    "type-check": "tsc --noEmit",
    "type-check:watch": "tsc --noEmit --watch",
    "test": "jest",
    "test:watch": "jest --watch"
  }
}

How to Migrate Specific Patterns#

React Hooks#

// useState
const [count, setCount] = useState<number>(0);
const [user, setUser] = useState<User | null>(null);

// useEffect
useEffect(() => {
  // Effect logic
  return () => {
    // Cleanup
  };
}, [dependency]);

// useRef
const inputRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<number | null>(null);

// useContext
const ThemeContext = createContext<'light' | 'dark'>('light');
const theme = useContext(ThemeContext);

// Custom hooks
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    const item = window.localStorage.getItem(key);
    return item ? JSON.parse(item) : initialValue;
  });
  
  const setValue = (value: T) => {
    setStoredValue(value);
    window.localStorage.setItem(key, JSON.stringify(value));
  };
  
  return [storedValue, setValue];
}

Express.js Routes#

import express, { Request, Response, NextFunction } from 'express';

const app = express();

// Typed request/response
app.get('/users/:id', (req: Request, res: Response) => {
  const userId = req.params.id;
  // Handle request
  res.json({ id: userId });
});

// Custom request types
interface AuthRequest extends Request {
  user?: User;
}

app.get('/profile', (req: AuthRequest, res: Response) => {
  if (!req.user) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  res.json(req.user);
});

// Error handling
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error' });
});

Async/Await Patterns#

// Basic async function
async function fetchData(): Promise<Data> {
  const response = await fetch('/api/data');
  return response.json();
}

// Error handling
async function fetchUserSafely(id: string): Promise<User | null> {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) return null;
    return response.json();
  } catch (error) {
    console.error('Failed to fetch user:', error);
    return null;
  }
}

// Multiple async operations
async function loadDashboard(): Promise<Dashboard> {
  const [user, posts, stats] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchStats(),
  ]);
  
  return { user, posts, stats };
}

Frequently Asked Questions#

Q: How long does a typical TypeScript migration take? A: It depends on project size. Small projects (< 10k lines) take 1-2 weeks. Medium projects (10k-50k lines) take 1-2 months. Large projects (>50k lines) can take 3-6 months with incremental migration.

Q: Should I migrate everything at once or gradually? A: For projects under 10,000 lines, migrate all at once. For larger projects, use incremental migration - new code in TypeScript, migrate existing code when you touch it. This minimizes disruption while making steady progress.

Q: What if my dependencies don't have TypeScript types? A: First, check if @types/[package-name] exists on npm. If not, create a .d.ts file with basic declarations. As a last resort, use declare module 'package-name' with any types, but plan to add proper types later.

Q: How do I handle the any type during migration? A: Start with any for complex cases, but mark them with TODO comments. Gradually replace with proper types. Use ESLint rule @typescript-eslint/no-explicit-any to track and reduce any usage over time.

Q: Should I enable strict mode immediately? A: No. Start with strict: false and allowJs: true. Once most files are migrated, enable strict checks one by one: noImplicitAny, then strictNullChecks, then full strict: true. This prevents overwhelming your team with errors.

Q: How do I convince my team to migrate? A: Focus on concrete benefits: fewer runtime bugs, better IDE support, easier refactoring. Start with a small pilot project to demonstrate value. Show metrics like reduced bug reports and faster development time.

Q: What's the best way to type third-party API responses? A: Create interfaces matching the API response structure. Use tools like quicktype.io to generate types from JSON. Consider using runtime validation libraries like Zod or io-ts to ensure runtime data matches your types.

Q: How do I handle dynamic JavaScript patterns in TypeScript? A: Use union types for multiple possible types, generics for reusable patterns, and type guards for runtime checks. For truly dynamic cases, use unknown instead of any and narrow the type with validation.

Conclusion#

Migrating to TypeScript is an investment that pays dividends in code quality, developer experience, and maintainability. The key is to approach it systematically:

Start small: Pick a low-risk module for your first migration Go gradual: Allow JavaScript and TypeScript to coexist Be pragmatic: Use any when needed, but plan to improve Measure progress: Track type coverage and error reduction Celebrate wins: Share improvements with your team

The migration might feel slow at first, but once you hit your stride, you'll wonder how you ever worked without TypeScript's safety net.


Further Reading: