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?
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:
Check for exact matches (e.g., string vs. string -> SAME).
Detect subtype changes (e.g., string vs. string | number -> MINOR).
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:
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