How Migrating a JavaScript Project to TypeScript Transformed Our Codebase: A Real-World Case Study

How Migrating a JavaScript Project to TypeScript Transformed Our Codebase: A Real-World Case Study cover image

Migrating any mature JavaScript codebase to TypeScript is a daunting prospect. But for our team, the journey wasn't just about adopting the latest trend—it was an essential step towards building a robust, maintainable, and scalable product. In this case study, I'll share how we approached the migration, the challenges we faced, the tools that helped us, and the remarkable improvements we saw in our day-to-day development.


The Starting Point: A Growing JavaScript Application

Our project—a SaaS dashboard for small businesses—had grown from a proof-of-concept side project to a critical product used by hundreds of customers. Built with React and a Node.js backend, the codebase had ballooned to over 100,000 lines of JavaScript.

Common Pain Points:

  • Runtime Errors: Bugs would manifest in production due to unexpected data shapes or null values.
  • Onboarding Friction: New developers struggled to grasp data flows and APIs without explicit type documentation.
  • Refactoring Anxiety: Large-scale changes felt risky. Without type safety, making improvements was a game of "find all the usages" by hand.
  • Documentation Drift: JSDoc comments and wiki pages would quickly become outdated or inconsistent.

It became clear that something needed to change.


Why TypeScript?

We evaluated several options, but TypeScript stood out for its:

  • Static Type-Checking: Catching bugs at compile time instead of runtime.
  • Developer Tooling: Enhanced editor support, autocomplete, and refactor tools.
  • Self-Documenting Code: Types as living documentation.
  • Ecosystem Support: Strong integration with React, Node, and third-party libraries.

Our Migration Plan

We knew a "big bang" rewrite was unrealistic. Instead, we opted for incremental migration, guided by the following principles:

  1. Upgrade the Build Process: Add TypeScript tooling without breaking existing builds.
  2. Adopt allowJs: Let TypeScript and JavaScript coexist, converting files gradually.
  3. Start at the Boundaries: Migrate API interfaces, utility functions, and core data models first.
  4. Leverage Community Tools: Use tools to automate as much as possible.

High-Level Migration Flow:

graph TD
    A[Set up TypeScript config] --> B[Enable allowJs and checkJs]
    B --> C[Convert config & utility files to .ts]
    C --> D[Gradually convert components/services]
    D --> E[Full TypeScript adoption]

The Migration Process

1. Setting Up TypeScript

We started by installing TypeScript and essential types for React and Node:

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

We then created a tsconfig.json:

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": true,
    "outDir": "./dist",
    "strict": true,
    "module": "commonjs",
    "esModuleInterop": true,
    "jsx": "react"
  },
  "include": ["src/**/*"]
}

2. Gradual Adoption with allowJs and checkJs

TypeScript supports gradual adoption. By enabling allowJs and checkJs, we could:

  • Keep using .js files.
  • Get type checking and errors in JS files with JSDoc annotations.
  • Convert files to .ts or .tsx incrementally.

3. Migrating Core Modules

We prioritized files with the most bugs or churn. For each file:

  • Renamed from .js to .ts or .tsx.
  • Added explicit type annotations.
  • Replaced any with more specific types as we went.

Example: Before and After

Original JavaScript:

function getUserProfile(user) {
  return user.profile.name + ' (' + user.profile.email + ')';
}

TypeScript Conversion:

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

interface User {
  profile: UserProfile;
}

function getUserProfile(user: User): string {
  return `${user.profile.name} (${user.profile.email})`;
}

4. Tooling That Helped

  • ts-migrate: Automated much of the initial conversion, adding any types as placeholders.
  • TypeScript ESLint: Ensured code quality and consistent type usage.
  • Type Coverage Tools: Measured progress towards full typing.

Challenges Faced

1. Third-Party Libraries

Some dependencies lacked type definitions, requiring us to:

  • Write custom .d.ts declaration files.
  • Switch to better-typed alternatives.

2. Type "Any" Proliferation

Automated tools would initially insert a lot of any types. We tackled this by:

  • Regularly reviewing and refining types.
  • Setting up ESLint rules to forbid unchecked any usage in new code.

3. Build and Tooling Issues

Outdated build tools and testing frameworks (Jest, Webpack) required upgrades for smooth TypeScript integration.


Tangible Benefits Post-Migration

1. Fewer Bugs

With static type-checking, many classes of runtime errors vanished. We caught null/undefined errors, wrong property accesses, and misused APIs during development—not in production.

2. Refactoring Confidence

Large-scale changes became safer. The compiler reliably pointed out every place a type change would ripple through the codebase.

3. Speedier Onboarding

New engineers could hover over variables to see their shape and intent. The codebase became largely self-documenting.

4. Improved Developer Experience

Autocomplete, go-to-definition, and inline documentation features in editors like VSCode became much more powerful.


Lessons Learned

1. Start Small, But Start Now

Incremental migration let us avoid major disruptions and learn TypeScript idioms gradually.

2. Invest in Types from the Start

Refining types as you go is easier than fixing a sea of any later. Prioritize core APIs and shared models.

3. TypeScript Is Not a Silver Bullet

It doesn't eliminate all bugs or replace good testing, but it dramatically reduces a large class of common errors.

4. Celebrate Milestones

Regularly measure and share progress—percent of files converted, reduction in any types, and improved code coverage. This kept morale high and stakeholders engaged.


Final Thoughts

Migrating to TypeScript transformed not just our codebase, but also our development culture. We moved from a "fix it when it breaks" mentality to a proactive, preventative approach. The upfront investment paid off in reduced bugs, happier developers, and a codebase that's ready for the future.

If you're considering (or dreading) a migration, remember: you don't have to do it all at once. Start with TypeScript in new files, migrate high-impact modules, and let the benefits speak for themselves.

Have you migrated a project to TypeScript? What challenges or surprises did you encounter? Share your experiences below!

Post a Comment

Previous Post Next Post