TypeScript Migration Guide: Convert JavaScript to TypeScript with tsconfig, ESLint, and Practical Examples
typescriptmigrationtsconfigeslintjavascript-to-typescript

TypeScript Migration Guide: Convert JavaScript to TypeScript with tsconfig, ESLint, and Practical Examples

TTypeScript Toolbox Editorial
2026-05-12
9 min read

Convert JavaScript to TypeScript with a practical migration plan, tsconfig setup, ESLint rules, and examples that scale.

TypeScript Migration Guide: Convert JavaScript to TypeScript with tsconfig, ESLint, and Practical Examples

Moving a real-world JavaScript codebase to TypeScript is less about rewriting everything and more about designing a safe migration path. The best migrations balance type safety, developer velocity, and incremental adoption. In this guide, you’ll learn how to convert JavaScript to TypeScript step by step, how to make pragmatic tsconfig decisions, how to configure TypeScript ESLint, and how to avoid the type inference pitfalls that slow teams down.

Why migration is an advanced TypeScript pattern, not just a syntax change

Many developers think a TypeScript migration is mostly about renaming .js files to .ts. In practice, a successful migration is an architectural exercise. You are introducing a type system into a codebase that already has runtime behavior, implicit assumptions, and undocumented contracts. That means your decisions around types, build tooling, linting, and folder structure matter as much as your actual type annotations.

This is why TypeScript migration belongs in the Advanced TypeScript Patterns pillar. The goal is not simply to “make the compiler happy.” The goal is to create a maintainable system where types support design decisions, catch regressions early, and make refactoring safer across features, services, and UI layers.

In larger applications, TypeScript also becomes a communication tool. Well-designed types document the shape of domain objects, API payloads, and component props. This is especially valuable in React applications, Next.js projects, and monorepos where many developers touch shared code.

Start with a migration strategy before touching tsconfig

The most common migration mistake is trying to apply strict typing everywhere on day one. A better approach is to choose a migration strategy that fits your codebase size and risk tolerance.

  • Incremental migration: Add TypeScript gradually, file by file or folder by folder.
  • Boundary-first migration: Type API layers, shared utilities, and data models before leaf UI components.
  • Feature-first migration: Convert one feature area at a time, keeping changes localized and testable.

For most production systems, incremental migration is the safest. It lets you keep shipping while you improve type coverage. You can start by allowing JavaScript files inside a TypeScript project, then progressively narrow the untyped surface area.

That approach also reduces pressure on teams learning TypeScript for beginners and advanced users alike. Developers can adopt the language without pausing feature work or rewriting stable code.

Before converting files, establish a clear project foundation. A good setup avoids painful toolchain surprises later.

1) Install the core dependencies

npm install -D typescript @types/node

If you are in a React project, also install the React type packages:

npm install -D @types/react @types/react-dom

2) Add a TypeScript configuration file

Start with a tsconfig.json that supports gradual adoption. For most teams, the key is to keep the compiler helpful without making migration impossible.

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Node",
    "strict": true,
    "allowJs": true,
    "checkJs": false,
    "noEmit": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "jsx": "react-jsx"
  },
  "include": ["src"],
  "exclude": ["node_modules"]
}

This is not the only valid setup, but it is a strong starting point. Here’s why these options matter:

  • allowJs: lets you keep existing JavaScript files during migration.
  • strict: enables the safety net that makes TypeScript worthwhile.
  • noEmit: useful when another tool handles transpilation, such as Vite, Next.js, or Babel.
  • skipLibCheck: helps reduce noise from third-party type definitions during adoption.

If you are building a large application or monorepo, consider separating type-checking from transpilation. That keeps builds fast and lets you tune the migration independently from runtime bundling.

How to convert JavaScript files without breaking the build

Once your configuration is ready, convert files gradually. Start with utility modules, shared helpers, and service-layer code. These usually have clearer inputs and outputs than UI code and give you quick wins.

Example: from JavaScript utility to TypeScript

Before:

export function formatPrice(price) {
  return `$${price.toFixed(2)}`;
}

After:

export function formatPrice(price: number): string {
  return `$${price.toFixed(2)}`;
}

This simple example shows the value of TypeScript examples in a migration guide: the compiler now ensures callers pass a number. If someone later passes a string or null, the issue surfaces early.

Example: typing an async API helper

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

export async function fetchUser(id: string): Promise {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error('Failed to fetch user');
  }
  return response.json() as Promise;
}

This works, but it still trusts the server response. In migration work, that assumption can become a hidden risk. A stronger pattern is to pair compile-time types with runtime validation.

Use runtime validation to close the gap between types and data

TypeScript types disappear at runtime, so they cannot protect you from malformed API responses or bad input. That is why runtime validation is an important companion to migration. In many teams, libraries such as Zod are used to validate JSON before the data enters the app state.

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});

type User = z.infer<typeof UserSchema>;

export async function fetchUser(id: string): Promise {
  const response = await fetch(`/api/users/${id}`);
  const json = await response.json();
  return UserSchema.parse(json);
}

This pattern improves both safety and maintainability. It also reduces the temptation to scatter as SomeType assertions throughout the codebase. If you are comparing Zod vs Yup TypeScript options, focus on how well the library supports schema inference, composability, and developer ergonomics in your project.

Understand type inference pitfalls before they become production bugs

One reason TypeScript migrations feel difficult is that the compiler often infers types in ways that are technically correct but not always ideal for your intent. Recognizing these patterns early saves hours of debugging later.

Common pitfalls

  • Implicit any in callbacks: especially in event handlers, map functions, and reducer logic.
  • Widened literal types: values like 'draft' become plain string unless preserved with as const.
  • Union narrowing gaps: conditional flows can lose type information if branches are not designed carefully.
  • Overuse of assertions: value as Type can silence the compiler instead of fixing the underlying problem.

A practical migration habit is to let inference work where the code is obvious, and add explicit types where intent matters. Good candidates for explicit typing include exported functions, API contracts, reducer actions, and shared domain objects.

const statuses = ['draft', 'published', 'archived'] as const;
type Status = typeof statuses[number];

function setStatus(status: Status) {
  return status;
}

This small pattern creates a more reliable contract than a generic string parameter. It also demonstrates the value of advanced TypeScript when migrating a mature JavaScript application.

TypeScript ESLint setup for clean migration and safer refactors

A migration is not complete without linting. TypeScript ESLint helps enforce consistency, prevent unsafe patterns, and guide developers away from fragile code.

A balanced lint setup should catch dangerous patterns without becoming so strict that it blocks progress. Start with rules that encourage type safety and predictable code style:

  • @typescript-eslint/no-explicit-any
  • @typescript-eslint/consistent-type-imports
  • @typescript-eslint/no-unused-vars
  • @typescript-eslint/ban-ts-comment with careful exceptions
  • @typescript-eslint/no-unsafe-assignment for high-risk code paths

If your team is just getting started, avoid turning every strict rule on immediately. Phase them in as the codebase becomes more typed. That keeps the migration focused on progress instead of compliance.

// eslint.config.js example
import tseslint from 'typescript-eslint';

export default tseslint.config({
  rules: {
    '@typescript-eslint/no-explicit-any': 'warn',
    '@typescript-eslint/consistent-type-imports': 'error',
    '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }]
  }
});

A well-tuned lint stack supports both new code and migrated code. It turns best practices into automation, which is especially useful in larger teams and monorepos.

React and Next.js migration tips

If your JavaScript app uses React or Next.js, you need to account for component props, hooks, and server/client boundaries. These frameworks benefit enormously from TypeScript, but they also reveal type issues quickly.

Type component props explicitly when the interface matters

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

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

For Next.js applications, also pay attention to server data fetching, route params, and environment variables. Type those boundaries carefully, because they often represent the most sensitive application contracts.

In React-heavy codebases, migration can be accelerated by introducing typed shared UI primitives first. Once those foundations are in place, features built on top become easier to migrate because the types are already established.

Monorepo and shared package considerations

Monorepos bring additional migration complexity because multiple packages may be at different stages of TypeScript adoption. The key is to define clear package boundaries and avoid leaky assumptions between apps and libraries.

Useful practices include:

  • Creating a shared base tsconfig and extending it per package.
  • Using project references for scalable type-checking.
  • Publishing only compiled outputs or well-defined source entries.
  • Typing package exports more strictly than internal implementation details.

This is where advanced TypeScript patterns shine. As the codebase grows, the type system becomes part of your architecture. Shared packages should define stable contracts, while apps consume those contracts without depending on implementation quirks.

For most JavaScript applications, this order works well:

  1. Enable TypeScript with allowJs so the app still runs.
  2. Type the shared utilities and domain models first.
  3. Add ESLint rules that prevent new unsafe patterns.
  4. Convert API clients and data-fetching code.
  5. Move into React components, hooks, and page-level code.
  6. Tighten tsconfig settings gradually until the project is fully typed.

This sequence minimizes risk because it prioritizes code with the highest leverage and the clearest contracts. It also ensures that teams see value quickly without waiting for a full rewrite.

What good looks like after the migration

A successful migration should produce measurable improvements:

  • Fewer runtime bugs caused by invalid data or function calls
  • Better autocomplete and faster onboarding for new contributors
  • Safer refactors across services, components, and shared utilities
  • Clearer API contracts and domain modeling
  • More confidence when scaling code across teams and packages

Equally important, the migration should remain sustainable. If your TypeScript setup becomes too strict too early, developers may work around the type system instead of with it. The goal is a codebase where types are trusted because they are accurate, not because they are enforced blindly.

If you are building broader internal tooling, explainable data flows, or analytics-heavy interfaces, these deeper TypeScript examples can help you think about contracts, modeling, and maintainability beyond a simple migration:

These articles complement migration work by showing how TypeScript supports structured data, reliable abstractions, and scalable developer-facing systems.

Final takeaway

A TypeScript migration guide should do more than explain syntax. It should help you make better engineering decisions. When you convert JavaScript to TypeScript with a deliberate plan, a thoughtful tsconfig, strong linting, and runtime validation where needed, you get a codebase that is easier to refactor and safer to grow.

Start small, type the boundaries first, and let the migration shape your architecture in a healthy way. That is the real power of TypeScript best practices in production systems.

Related Topics

#typescript#migration#tsconfig#eslint#javascript-to-typescript
T

TypeScript Toolbox Editorial

Senior SEO Editor

Senior editor and content strategist. Writing about technology, design, and the future of digital media. Follow along for deep dives into the industry's moving parts.

2026-05-13T18:37:04.314Z