I'm using the Typescript Compiler API type checker to determine the types of some identifier nodes in a given file that is loaded into a program.
I load the file, create the program and type checker instance like this:
const program = ts.createProgram([document.uri.fsPath], { allowJs: true, strict: true });
const checker = program.getTypeChecker();
const sourceFile = program.getSourceFile(document.uri.fsPath);
Then I go on to traverse the AST using the ts.forEachChild
. After doing some checks to ascertain that I've found a node I'm interested in knowing the type, I proceed to use the type checker to get its type, like this:
const type = checker.getTypeAtLocation(node);
const typeAsString = checker.typeToString(type, node, typeFormatFlag);
Consider, then, this file:
//spreadArray.tsx
import React from 'react';
interface ComponentProps {
items: string[];
}
function Component(props: ComponentProps) {
const {
items: [item1, item2, ...otherItems]
} = props;
return (
<div className='my-class-2'>
<div>{item1}</div>
<div>{item2}</div>
<div>{otherItems.join(', ')}</div>
</div>
);
}
I'm interested in knowing the types of item1
, item2
and otherItems
. When I hover over these variables in the original file, the Typescript Language Support in VS Code correctly give their types as string
, string
and string[]
, respectively.
When I run my program, I inspect the result to se any
, any
and {}
, respectively. This kind of wrong resolution happens to other types as well, like some arrow functions, promises, objects, etc.
When I run my program in an integration test suite, that feeds files like the above, it yields the correct types in more scenarios, but it still yields the wrong type sometimes. I haven't been able to find a pattern though.
The file that is fed to the program and the program live in the same environment, exposed to the same typescript version and tsconfig file, etc.
Both running as usual and running it through integration tests happens via a "Extension Development Host" instance of VS Code, as this code is part of a VS Code extension. The extension is already in production and running it as the via the production extension available in the marketplace yields the same kind of inconsistency observed as running it locally.
Here are some project configurations:
tsconfig.json
{
"compilerOptions": {
"jsx": "react",
"module": "Node16",
"target": "ES2022",
"lib": ["ES2022"],
"sourceMap": true,
"rootDir": "src",
"strict": true
}
}
package.json
//....
"devDependencies": {
"@types/mocha": "^10.0.6",
"@types/node": "18.x",
"@types/react": "^18.2.58",
"@types/react-dom": "^18.2.19",
"@types/vscode": "^1.86.0",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",
"@vscode/test-cli": "^0.0.4",
"@vscode/test-electron": "^2.3.9",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.2.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"ts-loader": "^9.5.1",
"webpack": "^5.90.0",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"typescript": "^5.3.3"
}
Node version: 18.17.1
Instead of trying to use the getTypeAtLocation(node)
method, I've tried to extract the type from the symbol with getSymbolAtLocation(node)
and getTypeOfSymbolAtLocation(symbol, node)
. Other versions of typescript 5.x
and testing in multiple codebases using React and Typescript were tried as well. All to no avail.
This kind of resolving to any
or sometimes unknown
and, specifically for arrays, {}
, has happened in all the codebases I've tested.
The only pattern I've been able to identify is that the more complex the expected type is, the likelier that it will show a wrong one.
I would like to understand what I'm doing wrong, as to increase the precision of the types given and more accurately match those that you would see by hovering over a variable in your IDE.
TL;DR: The instantiated typescript program wasn't able to find the JS API types.
The main problem: Lacking lib
argument at compilerOptions
Following Sherret's suggestion, I inspected the diagnostics with the following code:
const program = ts.createProgram([document.uri.fsPath], { allowJs: true, strict: true });
const emitResult = program.emit();
const allDiagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics);
allDiagnostics.forEach((diagnostic) => {
if (diagnostic.file) {
const { line, character } = ts.getLineAndCharacterOfPosition(diagnostic.file, diagnostic.start!);
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
console.log(`${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`);
} else {
console.log(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'));
}
});
When running in debug mode, the code gets transpiled and bundled with webpack
to the dist
folder, and the first error messages in the log would be something like Cannot find global type Number
for the types belonging to JS API.
After some research I found that passing the lib
option to the compilerOptions
of the program should fix this problem. For some reason, passing it as the API suggests, like ESNext
, would case the program to try to find a file literally named ESNext
and fail. Thus, I had to pass the complete file name, like this: lib.esnext.full.d.ts
.
That didn't work on its own, but after some debugging, I realized that if I only did the transpilation step, without bundling, the errors emitted would diminish substantially. Testing in debug mode now would yield the same results as in test mode.
Note: The observed difference in behavior between debug (prior to these changes) and test mode is that the latter only performed transpilation using tsc
and the output would go to the out
folder. For some reason, the program would find the @root/node_modules/typescript/lib
on its own, without me having to pass any lib
argument to the compilerOptions
when skipping the bundling.
Removing other errors from the console
The console was not yet clean and after reading through the messages, I found that adding jsx
and esModuleInterop
to the compilerOptions
worked well and made sense on this extension's scope, so that the program instance became this:
const program = ts.createProgram([document.uri.fsPath], {
allowJs: true,
strict: true,
lib: ['lib.esnext.full.d.ts'],
jsx: ts.JsxEmit.React,
esModuleInterop: true
});
Fix bundling problems
After bundling I would still have the issue, so the fix wouldn't work in production. The bundled code would complain that it couldn't find the lib
file in the path @root/dist/lib.esnext.full.d.ts
. And of course it wouldn't, because it was at @root/node_modules/typescript/lib
. But just pointing to the correct path wouldn't make it, as I wouldn't ship the node_modules
.
So what I had to do was to copy the lib
files to the dist
folder when bundling. I did this using the copy-webpack-plugin
.
As the lib
files depend on libs of previous versions in a cascade-like manner, the latter had to be copied as well:
// webpack.config.js
const CopyPlugin = require('copy-webpack-plugin');
const extensionConfig = {
// ...
plugins: [
new CopyPlugin({
patterns: [{ from: 'node_modules/typescript/lib/*.d.ts', to: '[name][ext]' }]
})
]
};
After that I only had to whitelist *.d.ts
files from the .vscodeignore
so that the files wouldn't be removed from the production package shipped to the VS Code marketplace.
// .vscodeignore
// ...
!dist/*.d.ts