TypeScript’s type checking isn’t actually slow; it’s just that your project has grown to a size where the compiler has to do a lot more work than you might expect.

Let’s see what’s happening under the hood. Imagine a simple index.ts file:

// index.ts
function greet(name: string): string {
  return `Hello, ${name}!`;
}

const message = greet("World");
console.log(message);

When you run tsc --noEmit on this, it’s lightning fast. Now, imagine a project with 10,000 files, each with hundreds of exported types and functions. The compiler has to:

  1. Parse every .ts file into an Abstract Syntax Tree (AST).
  2. Analyze the relationships between types across all files. This involves building a massive symbol table and type graph.
  3. Check each expression against the inferred or declared types.
  4. Resolve module imports, which means looking up and loading type information from other files.
  5. Report any discrepancies.

This entire process is computationally expensive, especially the type resolution and checking phases, which have a non-linear relationship with project size.

Diagnosing the Bottleneck

The first step is to figure out where TypeScript is spending its time. You need to enable profiling.

Command:

tsc --noEmit --generateTrace trace.json

This command will run the TypeScript compiler, perform type checking (but not emit any JS files), and generate a trace.json file. This file contains detailed timing information for every operation the compiler performs.

Analysis: Open trace.json in your browser by navigating to chrome://tracing. You’ll see a waterfall diagram of compiler events. Look for:

  • semantic-diagnostic: This is where type checking happens. If this is a large chunk of the total time, your type system is complex.
  • parse: If parsing is slow, it might indicate very large files or complex syntax.
  • get-type-of-node: This is a core part of type checking, where TypeScript determines the type of an expression.
  • resolve-module: Slow module resolution suggests many imports or a large node_modules structure.

Common Culprits & Fixes

  1. Excessive Declaration Files (.d.ts)

    • Diagnosis: In your trace.json, you’ll see a disproportionate amount of time spent in parse or get-type-of-node related to files within your node_modules/@types directory or custom .d.ts files.
    • Fix: Reduce the number of included declaration files. If you’re using libraries that are already typed (e.g., @types/react for React), but you’re also manually including .d.ts files for those same libraries, remove the redundant ones. For specific, large libraries you don’t use heavily, consider excluding their types from your tsconfig.json if possible.
      // tsconfig.json
      {
        "compilerOptions": {
          // ... other options
        },
        "exclude": [
          "node_modules/@types/some-large-library",
          "typings/redundant-library.d.ts"
        ]
      }
      
    • Why it works: TypeScript doesn’t need to parse and analyze declarations for types it never actually uses. Pruning these reduces the overall workload.
  2. Large, Complex Files

    • Diagnosis: The trace.json shows a significant portion of time spent on parse and get-type-of-node for a few specific, very large .ts files.
    • Fix: Break down monolithic files into smaller, more manageable modules. This often involves refactoring large classes or components into smaller, cohesive units.
    • Why it works: Smaller files are parsed faster, and the type checker has a more localized scope to analyze, reducing the combinatorial explosion of type relationships it needs to track.
  3. Overly Broad include or files in tsconfig.json

    • Diagnosis: The trace.json shows a lot of time spent parsing and analyzing files that are not directly part of your application logic (e.g., generated files, test fixtures you don’t need checked).
    • Fix: Be specific about what files TypeScript should check. Use include for your source code and exclude for everything else.
      // tsconfig.json
      {
        "compilerOptions": {
          // ...
        },
        "include": [
          "src/**/*.ts",
          "tests/**/*.ts"
        ],
        "exclude": [
          "node_modules",
          "dist",
          "**/*.spec.ts" // If you don't want .spec.ts files checked for types
        ]
      }
      
    • Why it works: TypeScript only processes files it’s explicitly told to. Narrowing the scope means less parsing and analysis.
  4. Deeply Nested Type Definitions and Generics

    • Diagnosis: trace.json shows get-type-of-node taking a long time, often involving complex generic instantiations or deeply nested conditional types.
    • Fix: Simplify complex type aliases. Break down intricate generic functions or types into simpler, more composable parts. Use intermediate type aliases to make complex types more readable and thus easier for the compiler to process.
      // Before (complex)
      type DeeplyNested<T, U> = T extends Array<infer R> ? (R extends object ? R : {}) : {};
      type Processed = DeeplyNested<Array<{ id: number }>, any>;
      
      // After (simpler)
      type InferElementType<T> = T extends Array<infer R> ? R : never;
      type ElementIfObject<T> = T extends object ? T : {};
      type ProcessedSimplified<T> = ElementIfObject<InferElementType<T>>;
      type Processed = ProcessedSimplified<Array<{ id: number }>>;
      
    • Why it works: Each level of indirection or conditional logic adds to the compiler’s work. Flattening these structures reduces the number of evaluation steps.
  5. Large node_modules Directory

    • Diagnosis: trace.json shows significant time in resolve-module and parse for files within node_modules.
    • Fix: Use npm prune or yarn autoclean to remove unused dependencies. Regularly run npm dedupe or yarn dedupe. Consider using tools like npm-check-updates to keep dependencies current, which can sometimes lead to smaller or more optimized packages.
    • Why it works: A smaller node_modules means fewer files to scan and resolve, and less type information to load and process.
  6. Using paths in tsconfig.json Inefficiently

    • Diagnosis: trace.json shows resolve-module taking a long time, especially when resolving imports that match paths aliases.
    • Fix: Ensure your paths are specific and don’t create circular dependencies or excessively broad wildcards that force the compiler to check many possibilities. Order them carefully if there’s overlap.
      // tsconfig.json
      {
        "compilerOptions": {
          // ...
          "baseUrl": ".",
          "paths": {
            "@utils/*": ["src/utils/*"],
            "@components/*": ["src/components/*"]
          }
        }
      }
      
    • Why it works: Well-defined paths help the compiler quickly locate modules. Overly general paths require more searching.

After applying these fixes, running tsc --noEmit again will show a noticeable improvement. The next error you’ll likely encounter is a performance hit during runtime if your JavaScript bundle size has increased due to less aggressive tree-shaking or if you’re dealing with complex runtime type checking.

Want structured learning?

Take the full Typescript course →