Search code examples
typescriptreact-typescripttypescript-compiler-api

The Typescript Compiler API Type Checker yields different types than those observed by Typescript Language support in VS Code


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.


Solution

  • 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