I am encountering an issue with the TypeScript type checker that I do not understand.
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;
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...
}
}
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
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;
};
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!