typescripttypescript-typingstypescript-compiler-api

How to Compare TypeScript Types Between Two Versions of a Project Using TypeScript Compiler API?


I have a TypeScript script that transpiles my project and extracts various declarations (exports, impots, classes, interfaces, types, functions, methods) along with their type parameters, arguments, and type values. The goal is to generate code snippets and JSDoc from the codebase and automatically inject it (which I did with eslint compiler).

I recently refactored my code to use the TypeScript Compiler API instead of ESLint to compare two snapshots of my project: an older version and a newer version. The objective is to deeply compare each extracted node and determine the type of change (PATCH, MINOR, MAJOR) based on semantic versioning.

Here are some examples of the comparison logic:

// SAME
type OLDER = string;
type NEWEST = string;

// SAME
class A {
    hello(): string {
        return "hello world";
    }
}
class B {
    hello(): string {
        return "hello world";
    }
}
type OLDER = A;
type NEWEST = B;

// MINOR
type OLDER = string;
type NEWEST = string | number;

// MINOR
type OLDER = string;
type NEWEST = any;

// MAJOR
type OLDER = string;
type NEWEST = number | null;

Below is a quick overview of my code:

// Extract all the exports/classes... with the TypeScript Compiler API
const olderProject = await Project.process(Path.join(__dirname, "./older/index.ts"), compilerOptions, resolutionHost);
const newestProject = await Project.process(Path.join(__dirname, "./newest/index.ts"), compilerOptions, resolutionHost);

// Note that the older and newest snapshots have 2 different programs, so 2 different checkers
const newestChecker = newestProject.program.getTypeChecker();
const olderChecker = olderProject.program.getTypeChecker();

// Get the type Test from the older and newest project extraction
const olderType = (() => {
    for (const file of olderProject.files) {
        for (const declaration of file.declarations) {
            if (declaration.isImport()) {
                continue;
            }
            if (declaration.name === "Test") {
                return olderChecker.getTypeAtLocation(declaration.source);
            }
        }
    }
})();

const newestType = (() => {
    for (const file of newestProject.files) {
        for (const declaration of file.declarations) {
            if (declaration.isImport()) {
                continue;
            }
            if (declaration.name === "Test") {
                return newestChecker.getTypeAtLocation(declaration.source);
            }
        }
    }
})();

// The goal is to have a function like:
enum Change {
    SAME = "SAME",
    MINOR = "MINOR",
    MAJOR = "MAJOR"
}
const compare = (older: Type, newest: Type): Change => {
    // ...
};

I have implemented the script, but I am facing several issues. One is related to recursive types like DeepArray<T> = T[] | DeepArray<T>, which create recursive loops. Another issue is the complexity of handling all possible type scenarios, making me worried about missing something.

Is there a simple way to compare if a type A satisfies a type B using the TypeScript Compiler API, even if the types come from different programs?


Solution

  • To address your problem of comparing types using the TypeScript Compiler API, we can leverage the isTypeAssignableTo method provided by the API, which checks if a type A satisfies type B . Here’s a breakdown of how to approach your issues and a solution:

    Key Challenges

    1. Recursive Types: Handling types like DeepArray can result in infinite loops if not managed correctly.

    2. Cross-Program Type Checking: Comparing types from two different programs requires careful handling of type references.

    3. Complex Type Scenarios: Handling unions, intersections, generics, and other advanced TypeScript features without missing any edge cases.

    Using isTypeAssignableTo

    The TypeChecker API in TypeScript includes a method, isTypeAssignableTo(source, target), which checks if source is assignable to target. This is a good starting point for comparing types.

    Solution: Compare Types for Semantic Versioning

    We’ll build a compare function that uses isTypeAssignableTo to determine changes between types. The function will:

    1. Check for exact matches (e.g., string vs. string -> SAME).

    2. Detect subtype changes (e.g., string vs. string | number -> MINOR).

    3. Detect incompatible or stricter changes (e.g., string vs. number | null -> MAJOR).

    Implementation

    Here’s an implementation using the TypeScript Compiler API:

    import ts from "typescript";
    
    enum Change {
      SAME = "SAME",
      MINOR = "MINOR",
      MAJOR = "MAJOR",
    }
    
    const compare = (
      older: ts.Type,
      newest: ts.Type,
      checker: ts.TypeChecker
    ): Change => {
      if (checker.isTypeAssignableTo(older, newest) && checker.isTypeAssignableTo(newest, older)) {
        // Types are exactly the same
        return Change.SAME;
      }
    
      if (checker.isTypeAssignableTo(older, newest)) {
        // Older is a subset of Newest (e.g., `string` -> `string | number`)
        return Change.MINOR;
      }
    
      // Types are incompatible or stricter (e.g., `string` -> `number | null`)
      return Change.MAJOR;
    };
    

    Handling Recursive Types

    The TypeScript Compiler API handles recursive types internally. When using isTypeAssignableTo, it avoids infinite loops caused by recursive structures like DeepArray. Thus, you don’t need to implement custom logic for recursion.

    Cross-Program Comparisons

    To compare types across different programs (e.g., olderProject and newestProject), ensure that:

    1. You use the appropriate TypeChecker for each type.
    2. Extract the relevant type from each program (e.g., using getTypeAtLocation).
    3. Pass the types to compare along with one of the TypeChecker instances.

    Full Example

    const compareTypesAcrossProjects = (
      olderType: ts.Type,
      newestType: ts.Type,
      olderChecker: ts.TypeChecker,
      newestChecker: ts.TypeChecker
    ): Change => {
      // Use `compare` function with the appropriate checker
      const olderToNewest = compare(olderType, newestType, newestChecker);
      const newestToOlder = compare(newestType, olderType, olderChecker);
    
      // If both are SAME, it's truly SAME
      if (olderToNewest === Change.SAME && newestToOlder === Change.SAME) {
        return Change.SAME;
      }
    
      // If older is assignable to newest, but not vice versa, it's MINOR
      if (olderToNewest === Change.MINOR) {
        return Change.MINOR;
      }
    
      // Otherwise, it's MAJOR
      return Change.MAJOR;
    };
    
    // Example usage:
    const result = compareTypesAcrossProjects(olderType, newestType, olderChecker, newestChecker);
    console.log(result); // Outputs SAME, MINOR, or MAJOR