Complete Guide to Migrating from JavaScript to TypeScript: Maximizing Type Safety, Bug Reduction, and Development Efficiency

  • typescript
    typescript
  • jest
    jest
Published on 2024/02/12

Introduction

Migrating from JavaScript to TypeScript greatly contributes to improved type safety and reduced bugs, but if you proceed without proper planning, it can place a burden on the development team.

In this article, we explain in detail everything from creating a migration plan, analyzing the codebase, identifying files to migrate, optimizing tsconfig.json, designing types and reducing any, to phased migration strategies.

Furthermore, we introduce best practices for achieving a smooth migration, including key refactoring points, early bug detection using TypeScript, optimizing the development environment (IDE and CI tool configuration), TypeScript training for the team, and strengthening tests after migration. This is a must-read guide for engineers who want to successfully migrate to TypeScript and improve development efficiency and code quality.

Clarifying the Purpose of Migration

First, clarify the purpose of migrating to TypeScript. Common objectives include the following:

  • Improved type safety: Reduce runtime errors and increase code reliability.
  • Improved development efficiency: Strengthened IDE completion features make it possible to detect bugs early.
  • Improved maintainability: In team development, use type information to clearly convey intent.
  • Improved compatibility with libraries and frameworks: Many modern libraries support TypeScript.

Identifying Files to Migrate

It is not realistic to convert all files to TypeScript at once. Therefore, it is important to first decide which files to prioritize for migration.

  • High priority

    • Utility functions (since they are used commonly, they easily benefit from types)
    • API clients (defining request/response types improves safety)
    • Modules with a small type impact range and high independence
  • Medium priority

    • Core components (defining component props types improves readability)
    • Service layer containing business logic
  • Low priority

    • Test code (you can benefit from types after migration, but it is not the top priority)
    • Temporary scripts or legacy code

Designing a Phased Migration Strategy

Migration should not be done all at once, but rather progressed in phases. The following strategies can be considered:

  1. Create tsconfig.json and introduce partial type checking
  2. Change .js files to .ts and check for errors
  3. Prioritize adding type definitions for utility functions and models
  4. Sequentially migrate components and the business logic layer
  5. Reduce any and increase type strictness
  6. Finally convert all .js files to .ts and apply TypeScript strict mode

Checking Required Libraries and Tools

Several libraries and tools are required for migrating to TypeScript. Install them in advance.

  • TypeScript core
    npm install --save-dev typescript
    
  • Type definition packages (@types)
    • Add type definitions appropriate for your project such as @types/react, @types/node
    npm install --save-dev @types/react @types/node
    
  • ESLint + TypeScript configuration
    npm install --save-dev eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
    
    • eslintrc.json configuration
    {
      "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
      "parser": "@typescript-eslint/parser",
      "plugins": ["@typescript-eslint"],
      "rules": {
        "@typescript-eslint/no-explicit-any": "warn",
        "@typescript-eslint/explicit-module-boundary-types": "off"
      }
    }
    
  • Jest (test environment) and ts-jest configuration
    npm install --save-dev jest ts-jest @types/jest
    

Preparing these in advance will allow you to smoothly proceed with the migration to TypeScript.

There is also a blog post on setting up Jest in a TypeScript environment, so please refer to it together.

https://shinagawa-web.com/en/blogs/jest-unit-testing-introduction

Optimizing the TypeScript Configuration File (tsconfig.json)

The TypeScript configuration file tsconfig.json is an important file that controls the compile settings of the project.

By configuring it properly, you can improve type safety and prevent errors during development in advance.

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

compilerOptions (Compiler Options)

Configure options that control the behavior of the compiler.

strict: true

  • Enables TypeScript’s strict type checking.
  • Equivalent to setting all strict* options below to true.
  • Recommendation: true (to maximize type safety)

noImplicitAny: true

  • Prohibits implicit any types.
  • Requires explicit typing and improves type safety.
  • Recommendation: true

strictNullChecks: true

  • Treats null and undefined properly as types.
  • Example: A variable of type string | null can be assigned null, but a variable of type string cannot be assigned null.
  • Recommendation: true (prevents mistakes in handling null)

strictFunctionTypes: true

  • Makes type checking of function parameters and return values stricter.
  • Strengthens type compatibility rules and prevents unintended misuse of functions.
  • Recommendation: true

moduleResolution: "node"

  • Performs Node.js-style module resolution (enables path resolution from node_modules).
  • Allows you to properly import external packages using import statements.
  • Recommendation: "node" (default for typical TypeScript projects)

esModuleInterop: true

  • Provides compatibility with ES modules that include default exports.
  • Ensures compatibility between libraries using CommonJS (require) and import.
  • Recommendation: true (to ensure compatibility with ES modules)

include (Target Directories)

Specify the directories that contain TypeScript files to be compiled.
By specifying "src", only .ts and .tsx files under src/ are targeted.

"include": ["src"]

exclude (Excluded Directories)

Specify directories to exclude from TypeScript compilation.
It is common to specify files that do not need compilation, such as "node_modules" and dist/.

"exclude": ["node_modules", "dist"]

Depending on the project, the following options are recommended:

forceConsistentCasingInFileNames: true

  • Prevents file name collisions due to differences in uppercase and lowercase (especially effective on Windows).
  • Recommendation: true

noUnusedLocals: true

  • Raises an error if there are unused local variables.
  • Recommendation: true (removes unnecessary variables and keeps code clean)

noUnusedParameters: true

  • Raises an error if there are unused function parameters.
  • Recommendation: true (encourages removal of unnecessary parameters and improves code clarity)

noFallthroughCasesInSwitch: true

  • Raises an error if you forget break in a switch statement case.
  • Recommendation: true (prevents bugs)

Type Design and Step-by-Step Migration Procedure

Type design determines the success of the migration. Proceed with the following steps:

  1. Change .js files to .ts
  2. Avoid any and define basic types
  3. Use type inference while applying appropriate types
  4. Add types to function parameters and return values

Before:

function sum(a, b) {
  return a + b;
}

After:

function sum(a: number, b: number) {
  return a + b;
}

Improving any Types and Type Safety

At first, you may be tempted to use any, but avoid it as much as possible and apply appropriate types.

Before:

function getUserData(): any {
  return fetch('/api/user').then(res => res.json());
}

After:

type User = {
  id: number;
  name: string;
  email: string;
};

async function getUserData(): Promise<User> {
  const response = await fetch('/api/user');
  return response.json();
}

Refactoring Code and Necessary Fixes

To organize code and improve readability and maintainability, perform the following refactorings.

Replacing require with import

Target: Code using CommonJS (require)
Action: Rewrite to ES Modules (import)

Before (CommonJS)

const util = require("./util");

After (ES Modules)

import util from "./util";

Reason

  • import enables static analysis and makes it easier to benefit from tree shaking
  • Works well with TypeScript and makes type inference easier
  • ES Modules will become the standard in the future, so unifying them improves readability and maintainability

Converting Class-Based Code to Functional Components

Target: React components using class
Action: Refactor to functional components using React Hooks

Before (Class-based component)

import React, { Component } from "react";

class Counter extends Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }

  increment = () => {
    this.setState({ count: this.state.count + 1 });
  };

  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={this.increment}>Increase</button>
      </div>
    );
  }
}

export default Counter;

After (Functional component)

import { useState } from "react";

const Counter = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increase</button>
    </div>
  );
};

export default Counter;

Reason

  • Functional components are simpler and more readable than class-based ones
  • You can use React Hooks such as useState, making it easier to reuse logic
  • You no longer need to worry about this in classes, reducing bugs

Organizing Type Definitions with interface and type

Target: Code using PropTypes or code without types
Action: Use TypeScript interfaces and types

Before (Using PropTypes)

import PropTypes from "prop-types";

const UserCard = ({ name, age }) => (
  <div>
    <p>Name: {name}</p>
    <p>Age: {age}</p>
  </div>
);

UserCard.propTypes = {
  name: PropTypes.string.isRequired,
  age: PropTypes.number,
};

export default UserCard;

After (Using TypeScript interface)

interface UserCardProps {
  name: string;
  age?: number;
}

const UserCard: React.FC<UserCardProps> = ({ name, age }) => (
  <div>
    <p>Name: {name}</p>
    <p>Age: {age}</p>
  </div>
);

export default UserCard;

Reason

  • PropTypes performs runtime type checking, whereas TypeScript can detect errors at compile time
  • Using interfaces and types provides type information to the IDE, improving development efficiency
  • Unifying type definitions improves code readability and maintainability

Early Bug Detection and Fixing Using TypeScript

By introducing TypeScript, you can detect bugs at compile time. For example, you can prevent errors like the following:

Before:

const user = getUserData();
console.log(user.name.toUpperCase()); // If user is null, this will error

After:

const user: User | null = await getUserData();
console.log(user?.name.toUpperCase());

Configuring TypeScript Support in IDEs and CI Tools

Once you introduce TypeScript, it is important to optimize IDE and CI settings to improve the development experience and code quality.
Here, we explain in detail ESLint settings for VS Code and type checking with GitHub Actions.

VS Code eslint + typescript-eslint Configuration

To statically analyze TypeScript code in VS Code and unify type checking and coding style, configure ESLint and @typescript-eslint.

Steps

  1. Install ESLint and TypeScript ESLint packages
npm install --save-dev eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
  1. Create the ESLint configuration file .eslintrc.js
module.exports = {
  parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaVersion: 'latest',
    sourceType: 'module',
  },
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
  ],
  rules: {
    '@typescript-eslint/no-unused-vars': ['error'],
    '@typescript-eslint/explicit-function-return-type': 'off',
  },
};
  1. Install the ESLint extension in VS Code

  2. Enable ESLint in VS Code settings.json

{
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  "eslint.validate": ["typescript", "typescriptreact"]
}

Verification
Run the following command to verify that ESLint is working correctly.

npx eslint src/**/*.ts --fix

Running tsc --noEmit in GitHub Actions for Type Checking

Steps

  1. Create a GitHub Actions workflow definition. Create .github/workflows/ci.yml and run tsc --noEmit for type checking.
name: TypeScript Check

on:
  pull_request:
  push:
    branches:
      - main

jobs:
  type-check:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run TypeScript type check
        run: npx tsc --noEmit

When you push to GitHub, tsc --noEmit will run on GitHub Actions and detect any type errors.

TypeScript Training for the Development Team

To help team members use TypeScript smoothly, the following initiatives are recommended.

Learning TypeScript Fundamentals (Type Inference, Union Types, Generics, etc.)

First, it is important to understand the main concepts of TypeScript and learn by actually writing code.

Type Inference

  • TypeScript can automatically infer types to some extent even without explicit type annotations
let message = "Hello, TypeScript"; // Inferred as string
let count = 10; // Inferred as number

Union Types

  • Allow a variable to accept multiple types
function printId(id: string | number) {
  console.log(`Your ID is: ${id}`);
}

printId(123);  // OK
printId("abc");  // OK

Generics

  • Define generic types to ensure flexibility in typing
function identity<T>(arg: T): T {
  return arg;
}

let result = identity<number>(10);  // Type: number
let text = identity<string>("Hello");  // Type: string

Type Aliases and Interfaces

  • Improve type reusability
type User = {
  id: number;
  name: string;
};

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

const user: User = { id: 1, name: "Alice" };
const product: Product = { id: 1, name: "Laptop", price: 1000 };

Sharing TypeScript Best Practices Within the Team

Share unified coding styles and best practices in practical situations to improve team productivity.

Best Practices to Share

  • Leverage type inference while identifying where types should be explicitly specified

    • Make use of type inference while avoiding ambiguous types (any)
  • Avoid unnecessary use of any

    • Enable noImplicitAny to prevent undefined any
    • Use unknown and never to enforce strict type checking
  • Explicitly specify types for function parameters and return values

function add(a: number, b: number): number {
  return a + b;
}
  • Error handling with type safety in mind
function fetchData(): Promise<string> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("Data received");
    }, 1000);
  });
}

async function main() {
  try {
    const data = await fetchData();
    console.log(data);
  } catch (error) {
    console.error("Error:", error);
  }
}

Promoting Type Improvements Through Reviews

During code reviews, check type usage and share better approaches within the team.

Review Points Related to Types

  • ✅ Are appropriate types specified? (Avoid unnecessary use of any)
  • ✅ Are there any redundant type annotations? (Is type inference being used appropriately?)
  • ✅ Are null and undefined properly considered?
  • ✅ Are union types and generics applied appropriately?

Example: Improving Types in Code Review

// ❌ Before improvement: using any
function getUser(id: any): any {
  return { id, name: "John Doe" };
}

// ✅ After improvement: specifying clear types
function getUser(id: number): { id: number; name: string } {
  return { id, name: "John Doe" };
}

Practical Learning Methods

  1. Hands-on workshops

    • Learn by actually writing TypeScript code
    • Example: “How to choose between type aliases and interfaces”
  2. Pair programming

    • Pair with an experienced member and learn while coding together
  3. Regular study sessions

    • Hold a weekly TypeScript best practices sharing session
    • Example: “Using union types and intersection types”
  4. Applying it to real projects

    • Start with small refactorings and gradually introduce TypeScript

Strengthening and Improving Tests After Migration

Introducing TypeScript also improves the reliability of tests.

  • Write typed tests with Jest and Testing Library
  • Introduce ts-jest to run TypeScript-based tests
  • Install @types/jest to strengthen type checking

Before:

test('should return user name', () => {
  expect(getUserData().name).toBe('Alice');
});

After:

test('should return user name', async () => {
  const user = await getUserData();
  expect(user.name).toBe('Alice');
});

Conclusion

In this article, we explained in detail the migration from JavaScript to TypeScript from the following perspectives:

  • Planning the migration from JavaScript to TypeScript
  • Analyzing the codebase and identifying files to migrate
  • Optimizing the TypeScript configuration file (tsconfig.json)
  • Designing types and creating step-by-step migration procedures
  • Improving any types and type safety
  • Refactoring code and making necessary fixes
  • Early bug detection and fixing using TypeScript
  • Configuring TypeScript support in IDEs and CI tools
  • TypeScript training for the development team
  • Strengthening and improving tests after migration

Migrating from JavaScript to TypeScript is not something that can be done overnight, but by proceeding in a planned manner, you can reliably improve type safety. Use the steps introduced in this article as a reference and gradually move forward with your migration.

Xでシェア
Facebookでシェア
LinkedInでシェア

Questions about this article 📝

If you have any questions or feedback about the content, please feel free to contact us.
Go to inquiry form