Migrating to TypeScript: A Practical Guide

January 28, 2019

TypeScript has crossed the threshold from interesting to essential. Major frameworks support it natively. Developer tooling has improved dramatically. The question for most JavaScript projects isn’t whether to adopt TypeScript, but how.

Here’s how to migrate existing codebases incrementally.

Why TypeScript Now

Mature Ecosystem

TypeScript in 2019:

Real Benefits

Catch bugs earlier:

// JavaScript - runtime error
function greet(user) {
  return `Hello, ${user.name}`;
}
greet({ nam: "Alice" }); // Typo, fails at runtime

// TypeScript - compile-time error
interface User {
  name: string;
}
function greet(user: User) {
  return `Hello, ${user.name}`;
}
greet({ nam: "Alice" }); // Error: Object literal may only specify known properties

Better refactoring:

Self-documenting code:

The Cost

Migration isn’t free:

But for projects with longevity, the investment pays off.

Migration Strategy

Incremental Over Big Bang

Don’t try to convert everything at once:

Week 1: Add TypeScript to build pipeline
Week 2: Convert utility functions
Week 3: Convert data models
Week 4: Convert a feature module
...
Week N: Remove all JavaScript files

Why incremental:

Loose to Strict

Start with permissive config, tighten over time:

// Phase 1: Get it compiling
{
  "compilerOptions": {
    "strict": false,
    "noImplicitAny": false,
    "strictNullChecks": false
  }
}

// Phase 2: Enable some checks
{
  "compilerOptions": {
    "noImplicitAny": true,
    "strictNullChecks": false
  }
}

// Phase 3: Full strict mode
{
  "compilerOptions": {
    "strict": true
  }
}

File-by-File Conversion

Rename .js to .ts, fix errors, repeat:

# Check how many files remain
find src -name "*.js" | wc -l

# Convert one file
mv src/utils/date.js src/utils/date.ts
npm run typecheck  # Fix errors

Setup

Initial Configuration

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2018",
    "module": "commonjs",
    "lib": ["ES2018"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": false,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "allowJs": true,  // Allow .js files
    "checkJs": false  // Don't type-check .js files yet
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

Key settings for migration:

Build Integration

Webpack:

module.exports = {
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.jsx']
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/
      }
    ]
  }
};

Node.js:

npm install ts-node typescript
npx ts-node src/index.ts

# Or compile first
npx tsc && node dist/index.js

Type Definitions

Install types for dependencies:

npm install --save-dev @types/node @types/express @types/lodash

For packages without types:

// src/types/untyped-package.d.ts
declare module 'untyped-package' {
  export function doSomething(arg: string): void;
  // Or if you don't want to type it fully:
  // export = any;
}

Conversion Patterns

The any Escape Hatch

When you can’t figure out types immediately:

// Temporary - fix later
function processData(data: any): any {
  // Complex logic that's hard to type right now
  return data.map((item: any) => transform(item));
}

// TODO: Replace with proper types

Use any to make progress, then come back.

Type Inference

Let TypeScript infer when obvious:

// Unnecessary - TypeScript infers this
const name: string = "Alice";
const numbers: number[] = [1, 2, 3];

// Better - let inference work
const name = "Alice";
const numbers = [1, 2, 3];

// Explicit when needed
function getUser(id: string): User {  // Return type helps readers
  return users.find(u => u.id === id)!;
}

Interface vs Type

Both work, pick one pattern:

// Interface - extendable, good for objects
interface User {
  id: string;
  name: string;
}

interface AdminUser extends User {
  permissions: string[];
}

// Type - good for unions, intersections
type Status = 'pending' | 'active' | 'closed';
type UserWithStatus = User & { status: Status };

Convention: interfaces for objects, types for everything else.

Handling Null/Undefined

With strictNullChecks:

// Error: Object is possibly undefined
function getLength(str?: string): number {
  return str.length;
}

// Option 1: Guard clause
function getLength(str?: string): number {
  if (!str) return 0;
  return str.length;
}

// Option 2: Non-null assertion (when you know better)
function getLength(str?: string): number {
  return str!.length;  // ! asserts non-null
}

// Option 3: Optional chaining
function getLength(str?: string): number {
  return str?.length ?? 0;
}

API Response Types

Type your API responses:

interface ApiResponse<T> {
  data: T;
  meta: {
    page: number;
    total: number;
  };
}

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

async function fetchUsers(): Promise<ApiResponse<User[]>> {
  const response = await fetch('/api/users');
  return response.json();
}

React Components

interface Props {
  user: User;
  onUpdate: (user: User) => void;
  isLoading?: boolean;
}

const UserCard: React.FC<Props> = ({ user, onUpdate, isLoading = false }) => {
  // Component implementation
};

Express Handlers

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

interface CreateUserBody {
  name: string;
  email: string;
}

app.post('/users', (
  req: Request<{}, {}, CreateUserBody>,
  res: Response,
  next: NextFunction
) => {
  const { name, email } = req.body;  // Typed!
});

Common Challenges

Third-Party Types Don’t Match

When @types package doesn’t match reality:

// Extend or override types
declare module 'problematic-library' {
  interface Options {
    newOption: boolean;  // Missing in @types
  }
}

Complex Generic Types

Start simple:

// Don't start here
type DeepPartial<T> = T extends object ? {
  [P in keyof T]?: DeepPartial<T[P]>;
} : T;

// Start here
interface PartialUser {
  id?: string;
  name?: string;
}

Add complexity only when needed.

Large Files

Break down before converting:

// big-file.js (2000 lines) - hard to convert
// Split first, then convert
// utils/validation.ts
// utils/transformation.ts
// utils/formatting.ts

Tracking Progress

Metrics

# Count files by extension
find src -name "*.ts" | wc -l  # TypeScript
find src -name "*.js" | wc -l  # JavaScript

# Percentage complete
echo "scale=2; $(find src -name '*.ts' | wc -l) / \
$(find src \( -name '*.ts' -o -name '*.js' \) | wc -l) * 100" | bc

Strict Mode Coverage

Track which files pass strict mode:

// tsconfig.strict.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "strict": true
  },
  "include": ["src/utils/**/*", "src/models/**/*"]  // Strict-ready files
}

Key Takeaways

TypeScript migration is a marathon, not a sprint. Consistent progress beats perfect planning.