79374791

Date: 2025-01-21 14:33:40
Score: 0.5
Natty:
Report link

I am encountering an issue with the TypeScript type checker that I do not understand.

Scenario

I have two files representing snapshots: newest.ts (the latest version) and older.ts (the previous version).

Content of older.ts:

export type A = number;

Content of newest.ts:

export type A = number | string;

Project Setup

My project uses the same TypeScript program and checker to process files as follows:

Project {
    static async process(entrypoints: string | string[], options: ts.CompilerOptions, host: ts.ModuleResolutionHost): Promise<Project> {
        if (!Array.isArray(entrypoints)) {
            return this.process([entrypoints], options, host);
        }

        const program = ts.createProgram(entrypoints, options);
        // Additional processing...
    }
}

Initialization Code

const compilerOptions: ts.CompilerOptions = {
    moduleResolution: ts.ModuleResolutionKind.NodeJs,
    baseUrl: process.cwd(), // Adjust baseUrl to your project structure
    target: ts.ScriptTarget.ESNext,
    module: ts.ModuleKind.CommonJS,
    strictNullChecks: true
};

const resolutionHost: ts.ModuleResolutionHost = {
    fileExists: (fileName: string) => ts.sys.fileExists(fileName),
    readFile: (fileName: string) => ts.sys.readFile(fileName),
    directoryExists: (directoryName: string) => ts.sys.directoryExists(directoryName),
    getCurrentDirectory: () => process.cwd(),
    useCaseSensitiveFileNames: () => true
};

const pathNewest = Path.join(__dirname, "./resources/newest.ts"); // The newest file
const pathOlder = Path.join(__dirname, "./resources/older.ts"); // The older file
    
const project = await Project.process([pathNewest, pathOlder], compilerOptions, resolutionHost);
    
const newestFile = project.files.find(file => file.filename === pathNewest);  // Extract the newest file
const olderFile = project.files.find(file => file.filename === pathOlder);  // Extract the older file
    
const checker = project.program.getTypeChecker();  // Get the checker for processing
compare(checker, olderFile, newestFile);  // Compare the two files

Comparison Function

I implemented a comparison function inspired by your approach:

const compare = (checker: ts.TypeChecker, older: File | Exportable, newest: File | Exportable): UpdateType => {
    if (older.isFile() && newest.isFile()) {
        // File processing logic...
    }
    
    if (older.isType() && newest.isType()) {
        // `older.source` & `newest.source` are `ts.TypeAliasDeclaration` extracted from the same program.
        const olderType = checker.getApparentType(checker.getTypeAtLocation(older.source));
        const newestType = checker.getApparentType(checker.getTypeAtLocation(newest.source));
    
        // I also tried without using the apparent type:
        // const olderType = checker.getTypeAtLocation(older.source);
        // const newestType = checker.getTypeAtLocation(newest.source);    
        
        console.log(older.source.symbol.parent.escapedName);  // OUTPUT: "<PROJECT_PATH>/resources/older"
        console.log(newest.source.symbol.parent.escapedName);  // OUTPUT: "<PROJECT_PATH>/resources/newest"
        // Both types come from their respective newest & older file.

        console.log(`${checker.typeToString(olderType)} --> ${checker.typeToString(newestType)} = ${checker.isTypeAssignableTo(olderType, newestType)}`);
        // OUTPUT: A --> A = true

        console.log(`${checker.typeToString(newestType)} --> ${checker.typeToString(olderType)} = ${checker.isTypeAssignableTo(newestType, olderType)}`);
        // OUTPUT: A --> A = true (Unexpected: `string` should not be assignable to `number | string`)

        if (checker.isTypeAssignableTo(olderType, newestType) && checker.isTypeAssignableTo(newestType, olderType)) {
            return UpdateType.SAME;  // Unexpected result
        }
        
        if (checker.isTypeAssignableTo(olderType, newestType)) {
            return UpdateType.MINOR;
        }

        return UpdateType.MAJOR;
    }
    
    return UpdateType.MAJOR;
};

Issue

The problem I am facing is that the type checker incorrectly reports:

A --> A = true
A --> A = true

Even though the type has changed from number to number | string, the checker still considers both assignable in both directions. I expected the check from newestType to olderType to return false, as string is not assignable to number.

Do you have any insights into why the checker might behave this way? Could there be a scope issue? Am I missing something in the setup that could lead to this behavior? Like cache or something?

Thanks for your help!

Reasons:
  • Blacklisted phrase (0.5): Thanks
  • Whitelisted phrase (-0.5): Thanks for your help
  • Whitelisted phrase (-2): solution:
  • RegEx Blacklisted phrase (2.5): Do you have any
  • Long answer (-1):
  • Has code block (-0.5):
  • Contains question mark (0.5):
  • Self-answer (0.5):
  • Low reputation (0.5):
Posted by: jeremie dupas