CodeFixesHub
    programming tutorial

    Migrating a JavaScript Project to TypeScript (Step-by-Step)

    Migrate a JS codebase to TypeScript with practical steps, tooling, and examples. Improve safety and maintainability—follow this hands-on tutorial now.

    article details

    Quick Overview

    TypeScript
    Category
    Sep 25
    Published
    19
    Min Read
    2K
    Words
    article summary

    Migrate a JS codebase to TypeScript with practical steps, tooling, and examples. Improve safety and maintainability—follow this hands-on tutorial now.

    Migrating a JavaScript Project to TypeScript (Step-by-Step)

    Introduction

    Migrating an existing JavaScript project to TypeScript can feel intimidating: a large codebase, many dependencies, and the fear of breaking behavior. Yet moving to TypeScript pays off quickly in improved developer experience, early error detection, and clearer contracts. In this guide for intermediate developers, you'll get a practical, step-by-step migration plan covering audit, setup, configuration, typing strategies, and deployment. We'll include real examples, code snippets, troubleshooting tips, and advanced techniques so you can migrate incrementally and confidently.

    By the end of this tutorial you will be able to:

    • Plan a safe migration path for an app of any size
    • Configure TypeScript tooling and build systems
    • Use incremental strategies like allowJs and isolated file migrations
    • Author or consume declaration files for third-party libraries
    • Apply stricter compiler flags and refactor code safely

    This article balances actionable steps and conceptual background. If you prefer a quick configuration reference, our intro to Introduction to tsconfig.json is a concise companion to the configuration sections here.

    Background & Context

    TypeScript adds a static type system on top of JavaScript without changing runtime behavior. It helps catch a wide range of bugs at compile time, enforces clearer APIs, and makes refactors safer. For teams, TypeScript improves onboarding and documentation because types are human-readable contracts. Migrating a mature JavaScript project should be incremental: you don't need to convert every file at once. With proper configuration, you can adopt TypeScript file-by-file while keeping your build and tests running.

    Key migration drivers include reducing runtime crashes, enabling IDE autocompletion, and improving long-term maintainability. Before you start, understand your project's complexity: number of files, third-party dependencies, build system, and test coverage.

    Key Takeaways

    • Plan an incremental migration rather than a big-bang rewrite
    • Configure tsconfig.json for incremental adoption and safe defaults
    • Use compiler flags to guide stricter typing (start weak, then strengthen)
    • Provide declaration files for JS modules or adopt DefinitelyTyped packages
    • Use type guards, mapped types, and conditional types to encode complex invariants
    • Integrate TypeScript into CI to enforce consistency

    Prerequisites & Setup

    You should know modern JavaScript (ES6+), npm or yarn, and have an editor with TypeScript support (VS Code recommended). Install the TypeScript compiler and a type-aware linter:

    javascript
    # install TypeScript and typescript-node-dev for development
    npm install --save-dev typescript ts-node-dev
    npm install --save-dev eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin

    Create a minimal tsconfig.json (we'll expand it later). For reference and deeper configuration guidance, see our guide on Introduction to tsconfig.json and the focused notes on Setting Basic Compiler Options: rootDir, outDir, target, module.

    Main Tutorial Sections

    1) Audit & Plan the Migration

    Start with an inventory: list entry points, tests, shared utilities, and third-party libraries. Identify high-risk modules (complex algorithms, external integrations). Choose a migration strategy: "gradual file-by-file", "module-by-module", or "rewrite small, convert large". Gradual is safest: convert pure utilities first, then business logic, and finally UI or integration code. Record APIs that need explicit typing and modules that lack type declarations. Use code coverage to prioritize converting code exercised by tests.

    Practical step: produce a migration backlog and a small milestone for the first week (e.g., convert 10 utility files and add build step).

    2) Initialize TypeScript and Base Configuration

    Create tsconfig.json with conservative defaults to avoid blocking work. Example starter config:

    javascript
    {
      'compilerOptions': {
        'target': 'ES2018',
        'module': 'commonjs',
        'outDir': 'dist',
        'rootDir': 'src',
        'allowJs': true,
        'checkJs': false,
        'esModuleInterop': true
      },
      'include': ['src/**/*']
    }

    This enables compiling existing JS files while letting you add .ts gradually. For a deep dive into compiler options like rootDir, outDir, target, and module, consult Setting Basic Compiler Options: rootDir, outDir, target, module.

    Step-by-step: add this tsconfig.json, run npx tsc --noEmit to validate, then add tsc to your build/test scripts.

    3) Add TypeScript to Build & Tooling

    Integrate TypeScript into your build and test pipelines. If you're using a bundler (Webpack, Rollup, Vite), install the TypeScript loader or plugin. For Node apps, prefer ts-node for dev and compile-only for production builds:

    javascript
    # build script
    'tsc'
    # dev script
    'ts-node-dev --respawn src/index.ts'

    If bundling with Webpack, use ts-loader or babel-loader with @babel/preset-typescript. Keep test frameworks compatible: update Jest to use ts-jest or babel-jest. Ensure linting includes TypeScript rules using @typescript-eslint.

    4) Incremental File Migration (allowJs & checkJs)

    Using allowJs: true lets the compiler accept .js files so you can gradually rename files to .ts or .tsx. Start by converting files with the fewest dependencies. Rename a single file to .ts and fix the first compiler errors. Use checkJs: true optionally to get type-check feedback in .js files before renaming.

    Example workflow:

    1. Set allowJs: true and checkJs: true in tsconfig.json.
    2. Fix issues surfaced in high-priority files.
    3. Rename files to .ts one by one.

    This approach reduces risk by allowing you to iterate without breaking builds.

    5) Choosing and Evolving Compiler Strictness

    Avoid enabling all strict flags immediately. Start with useful ones and progressively tighten rules. Key flags: noImplicitAny, strictNullChecks, and the umbrella strict. noImplicitAny helps surface missing types; read strategies in Using noImplicitAny to Avoid Untyped Variables.

    Suggested path:

    • Phase 1: strict: false, noImplicitAny: false (baseline)
    • Phase 2: enable noImplicitAny and fix high-value modules
    • Phase 3: enable strictNullChecks/strictFunctionTypes
    • Phase 4: flip on strict once codebase is stable

    Use per-file overrides via // @ts-nocheck sparingly and prefer // @ts-ignore with comments where temporary.

    For a practical explanation of strictness flags and recommended options, consult Understanding strict Mode and Recommended Strictness Flags.

    6) Typing Module Boundaries & Public APIs

    Concentrate on public interfaces between modules. Define types for service APIs, utility function signatures, and data models first. This gives the biggest safety return. Example: replace a loosely typed function:

    javascript
    // before
    function fetchUser(id) {
      return api.get('/user/' + id)
    }
    
    // after
    function fetchUser(id: string): Promise<User> {
      return api.get('/user/' + id)
    }
    
    interface User {
      id: string
      name: string
      email?: string
    }

    Document types in code, and export them so consumers benefit. Refactor any implicit any results by providing explicit return types and parameters.

    7) Working with Un-typed Third-Party Libraries

    Many npm packages lack TypeScript types. First, search for a maintained type package on DefinitelyTyped. Install types as dev dependencies:

    javascript
    npm install --save-dev @types/some-lib

    Our guide on Using DefinitelyTyped for External Library Declarations explains how to find and manage these declarations.

    If types are unavailable, author a minimal .d.ts file to describe the surface you use. See our practical guide to Writing a Simple Declaration File for a JS Module. Keep declarations minimal and iterate as usage grows. For complex cases, use the troubleshooting guide Troubleshooting Missing or Incorrect Declaration Files in TypeScript.

    8) Authoring Declaration Files and Global Typings

    When you have JS-only modules or global variables, create .d.ts files. Example minimal declaration for a simple module:

    javascript
    // types/my-lib.d.ts
    declare module 'my-lib' {
      export function greet(name: string): string
    }

    For globals, use declare global and include the file in tsconfig.json typeRoots or include. For a fuller introduction to declaration files and strategies for typing existing JS, read Introduction to Declaration Files (.d.ts): Typing Existing JS and Declaration Files for Global Variables and Functions.

    9) Advanced Type Techniques & Runtime Guards

    As you migrate, you may need richer types: discriminated unions, mapped types, conditional types, and inference. Use conditional and mapped types to represent transforms. For runtime checks, write custom type guards:

    javascript
    function isUser(obj: any): obj is User {
      return obj && typeof obj.id === 'string'
    }

    Custom type guards improve narrowing and work with TypeScript's control flow. See Custom Type Guards: Defining Your Own Type Checking Logic and the language behavior in Control Flow Analysis for Type Narrowing in TypeScript.

    10) Tests, CI, and Incremental Safety

    Finally, update tests to run against compiled TypeScript or use test runners configured for TS. Add tsc --noEmit in CI to fail builds on type errors. Example CI snippet (npm):

    javascript
    # package.json scripts
    "scripts": {
      "build": "tsc",
      "type-check": "tsc --noEmit",
      "test": "jest"
    }

    Add type-check as a required job step in your CI pipeline. Over time, raise strictness flags and make type-checking a gating step to prevent regressions.

    Advanced Techniques

    After a successful baseline migration, optimize developer ergonomics and type expressiveness. Use advanced mapped types and remapped keys with as to produce derived types for transformations; for example, convert API response shapes to client models. Learn patterns around mapped types and key remapping in Key Remapping with as in Mapped Types — A Practical Guide and the foundational mapped type syntax in Basic Mapped Type Syntax ([K in KeyType]).

    Leverage conditional types with infer to abstract parsing of complex generics; see Using infer in Conditional Types: Inferring Type Variables for patterns. Also, refine union handling and discriminated unions to simplify exhaustive checks and safe reducers. For performance, avoid excessive type-level recursion and keep complex types isolated behind well-defined module boundaries to reduce compile-time cost.

    Best Practices & Common Pitfalls

    Dos:

    • Migrate incrementally; prefer small PRs
    • Focus on public API types first
    • Use tsc --noEmit in CI early
    • Prefer explicit types at module boundaries

    Don'ts:

    • Don't enable all strict flags at once on a large, untyped codebase
    • Avoid using any as a long-term crutch; use it temporarily with TODOs
    • Don't rewrite large areas without tests

    Troubleshooting tips: if you hit mysterious errors from 3rd-party types, try removing node_modules/@types entries and reinstalling, or pin type package versions. When modules have incorrect declarations, our guide to Troubleshooting Missing or Incorrect Declaration Files in TypeScript is helpful. And if you need to reference internal type files directly, consider Understanding /// Directives in TypeScript.

    Real-World Applications

    Examples where migration yields immediate ROI:

    • Backend Node services: static typing for DTOs and DB models prevents serialization errors
    • Shared UI component libraries: typed props and style contracts reduce runtime UI bugs
    • Internal utilities / monorepos: consistent types across packages improve refactor safety

    For consumer libraries, author .d.ts files before publishing or rely on DefinitelyTyped. If you maintain a library with global augmentation, follow patterns described in Declaration Files for Global Variables and Functions.

    Conclusion & Next Steps

    Migrating to TypeScript is a long-term investment that pays dividends in developer productivity and code quality. Start small, add tooling, enforce type checks in CI, and gradually enable stricter compiler flags. Next steps: convert critical modules, add declaration files where needed, and explore advanced type utilities to make your codebase robust and self-documenting.

    Suggested reading path: begin with Introduction to tsconfig.json, enable noImplicitAny with help from Using noImplicitAny to Avoid Untyped Variables, and learn declaration strategies from Introduction to Declaration Files (.d.ts): Typing Existing JS.

    Enhanced FAQ

    Q1: How do I decide between converting .js files to .ts vs leaving them as .js with type checks? A1: Use allowJs and checkJs to get type feedback in .js files before renaming. Convert files that benefit most from type safety (core logic, shared utilities). Keep large stable UI files as .js while you prioritize critical areas. Conversion should be value-driven rather than exhaustive.

    Q2: What if a library I use has no types? A2: First search DefinitelyTyped and install @types/library if available. Our guide Using DefinitelyTyped for External Library Declarations explains this. If none exists, author a minimal .d.ts that covers the API surface you call; see Writing a Simple Declaration File for a JS Module. Keep the declarations minimal and iteratively expand them.

    Q3: Is it safe to enable strict mode on large codebases? A3: Enabling strict is ideal but can create a large initial workload. Roll it out gradually: enable noImplicitAny, fix critical areas, then strictNullChecks, and finally strict. Use per-file tsconfig overrides or // @ts-expect-error comments as temporary stops.

    Q4: How do I deal with circular type dependencies between modules? A4: Break cycles by extracting shared interfaces into a separate package or file that both modules import. Consider using interface-only modules that contain types and avoid runtime imports. If refactor is impossible short-term, keep the module as .js and incrementally refactor.

    Q5: My TypeScript build is slow—how can I improve it? A5: Use incremental builds by setting incremental: true and tsBuildInfoFile in tsconfig.json. Keep complex generic-heavy types localized to reduce recalculation. Use project references for monorepos to parallelize builds. Avoid excessive conditional or recursive type computations in widely imported types.

    Q6: How should I test TypeScript-specific issues? A6: Add tsc --noEmit as a CI step to catch type regressions. Complement with unit tests that exercise typed behavior and integration tests that verify runtime shape expectations. Use @ts-expect-error to mark intended type errors in test files, and remove them when fixed.

    Q7: When should I write a .d.ts file vs using any? A7: Prefer .d.ts for library surfaces and modules you publish or share widely. Use any only as a temporary accommodation, and add TODOs to track cleanup. Minimal, precise .d.ts files are better than broad any usage because they document intent and enable IDE help.

    Q8: How do I handle default exports and interop issues? A8: Set esModuleInterop: true in tsconfig.json to smooth importing CommonJS modules. When disabled, use import * as pkg from 'pkg' syntax. For projects migrating from Babel, ensure your bundler and TypeScript compiler agree on module resolution and transpilation semantics.

    Q9: How do I use custom type guards effectively? A9: Write type guard functions that return x is Type and use them to narrow unions and unknown values. They work hand-in-hand with TypeScript's control flow analysis. For patterns and examples, see Custom Type Guards: Defining Your Own Type Checking Logic and Control Flow Analysis for Type Narrowing in TypeScript.

    Q10: What are good indicators that migration is complete? A10: There will rarely be a point where migration is truly "complete." Good milestones: majority of core modules are .ts, CI enforces type checks, critical third-party libraries are typed, and team standards favor typed commits. Continue to tighten strictness and maintain declaration files as dependencies evolve.

    If you'd like, I can generate an initial tsconfig.json tailored to your project layout and provide a prioritized migration backlog based on your repo structure. Which build system and framework (Node, React, Next.js, etc.) are you using?

    article completed

    Great Work!

    You've successfully completed this TypeScript tutorial. Ready to explore more concepts and enhance your development skills?

    share this article

    Found This Helpful?

    Share this TypeScript tutorial with your network and help other developers learn!

    continue learning

    Related Articles

    Discover more programming tutorials and solutions related to this topic.

    No related articles found.

    Try browsing our categories for more content.

    Content Sync Status
    Offline
    Changes: 0
    Last sync: 11:19:53 PM
    Next sync: 60s
    Loading CodeFixesHub...