Leveraging the TypeScript API to find issues in your code

Did you know TypeScript has an API that allows you to write scripts that make certain checks on your code? It's very rarely useful for application developers, but it's used by IDE plugins and linting tools (e.g., ESLint). Interestingly, in the last year, we've had two situations where the TypeScript API was useful for us at work.

boolean -> Loading<boolean>

Recently at work, I decided to refactor various functions that previously returned boolean to return Loading<boolean>. For context, Loading<T> is a utility type that is defined like this:

type Loading<T> =
  | { loading: true }
  | { loading: false; data: T };

function isReady(...): boolean {}
// ...was refactored to...
function isReady(...): Loading<boolean> {}

The main problem with this refactor is that we had a lot of calls to our functions such as:

if (isReady(...)) {
  // do A
} else {
  // do B
}

After our refactor, this code would always go down the A path since objects are truthy in JavaScript and isReady now returns an object as opposed to a boolean. This is a problem, especially because I was refactoring various functions at once and looking through all calls to these functions is not feasible (too many functions and too many calls for each function, and many of the calls to these functions are not situations that need to be looked at).

With this in mind, I remembered that year, one of our Summer interns (Brian Chen) had written a script that used the TypeScript API to find some issues in our code when I took on another refactor.

I was inspired by his idea and wrote a script which proved helpful for this situation. Let's take a look at the script and then dissect its various parts to understand how it works.

function checkTypes(
    fileNames: string[],
    options: ts.CompilerOptions
): void {
    function visit(node: ts.Node) {
        const sourceFile = node.getSourceFile();
        const { fileName } = sourceFile;
        const line = ts.getLineAndCharacterOfPosition(sourceFile, node.getStart()).line + 1;

        if (ts.isIfStatement(node)) {
            const opt = checker.typeToString(checker.getTypeAtLocation(node.expression));

            if (opt.includes("Loading")) {
                console.log(`[If statement]: ${fileName}:${line}:operator on ${opt}: ${node.expression.getText()}`);
            }
        } else if (ts.isPrefixUnaryExpression(node)) {
            const opt = checker.typeToString(checker.getTypeAtLocation(node.operand));

            if (opt.includes("Loading")) {
                console.log(`[Prefix Unary Expression]: ${fileName}:${line}:operator on ${opt}: ${node.operand.getText()}`);
            }
        }

        ts.forEachChild(node, visit);
    }

    let program = ts.createProgram(fileNames, options);
    let checker = program.getTypeChecker();

    for (const sourceFile of program.getSourceFiles()) {
        ts.forEachChild(sourceFile, visit);
    }
}

(This is just the core function of the script, scroll down for the full script.)

The core function of our program is checkTypes which receives a list of file names as well as a set of TypeScript options and then visits every node (DFS) in the AST of our program. For each node, it calls the visit function which checks for 2 different node types:

  • If Statement (e.g., if (a))
  • Prefix Unary Expression (e.g. !a)

(A list of all the different types of nodes in the Typescript AST can be found here)

For each of these node types, it checks to see if their expressions operate on something that looks like the Loading type, and if they are, it reports it with console.log.

So if I run my script on this function:

export function f(a: Loading<number>, b: Loading<number>) {
    if (!a) {
        return;
    }
    
    if (b) {
        return;
    }
}

Then it will report:

[Prefix Unary Expression]: src/file.tsx:7:operator on Loading<number>: a
[If statement]: src/file.tsx:11:operator on Loading<number>: b

This is pretty neat! I can use this to track down a lot of operations that were fine with booleans but that aren't fine with objects. However, this doesn't catch everything. Just as an example, if I had:

export function f(a: Loading<number>, b: Loading<number>) {
    if (!a) {
        return;
    }
    
    if (b) {
        return;
    }
    
    const c = Boolean(b);
    if (c) {
        return;
    }
}

The third if statement wouldn't get flagged by the script, and it is just as dangerous as the first one. There's other situations where my script wouldn't work as well, but it was still helpful for my purpose.  

BigNumber and number

Last year, when Brian came up with the idea of using the TypeScript API to find issues with our code, the situation wasn't that different. Back then, I was refactoring a lot of our usages of the number type to a BigNumber class from an open source library. This created a lot of problems in our code in situations like this:

function f(x: number, y: number) { ...}
// was refactored to
function f(x: BigNumber, y: BigNumber) {
  if (!x) {
  
  }
  
  if (x > y) {
  
  }
  
  if (y) {
  
  }
}

None of these 3 if statements are problematic when using regular number variables. However, since a BigNumber is actually an object, none of these work the way we'd expect them to and TypeScript does not generate errors for this. So, the script Brian wrote checked for the following node types:

  • Prefix Unary Expression (e.g., !a)
  • Conditional Expression (e.g., a > b)

Once again, there were also many problematic situations with the refactor that the script didn't catch, but it helped a lot.

Avoiding all this

None of the situations described above are great situations to be in. Perhaps it would be better if TypeScript was a little more strict about what you can assert over in an if statement or any logical expression. To help with this, we can use the strict-boolean-expressions rule from typescript-eslint.

This ESLint plugin allows you to forbid usages of non boolean types in expressions where a boolean is expected (this is more generic than just if statements). Notice that this linting rule leverages the TypeScript API as well 😃.

While I've never personally used this ESLint rule, if I were to start a new TypeScript project, I'd probably try to get it set up from the very beginning to avoid the situations encountered above.

Finally, if you're looking for the full script, here it is:

// Calling this script:
// tsc script.ts
// node script.js src/**/*.tsx

import * as ts from "typescript";

function checkTypes(
    fileNames: string[],
    options: ts.CompilerOptions
): void {
    function visit(node: ts.Node) {
        const sourceFile = node.getSourceFile();
        const { fileName } = sourceFile;
        const line = ts.getLineAndCharacterOfPosition(sourceFile, node.getStart()).line + 1;

        if (ts.isIfStatement(node)) {
            const opt = checker.typeToString(checker.getTypeAtLocation(node.expression));

            if (opt.includes("Loading")) {
                console.log(`[If statement]: ${fileName}:${line}:operator on ${opt}: ${node.expression.getText()}`);
            }
        } else if (ts.isPrefixUnaryExpression(node)) {
            const opt = checker.typeToString(checker.getTypeAtLocation(node.operand));

            if (opt.includes("Loading")) {
                console.log(`[Prefix Unary Expression]: ${fileName}:${line}:operator on ${opt}: ${node.operand.getText()}`);
            }
        }

        ts.forEachChild(node, visit);
    }

    let program = ts.createProgram(fileNames, options);
    let checker = program.getTypeChecker();

    for (const sourceFile of program.getSourceFiles()) {
        ts.forEachChild(sourceFile, visit);
    }
}

// Tweak the compiler options to your need (look at your tsconfig.json)
checkTypes(process.argv.slice(2), {
    target: ts.ScriptTarget.ESNext,
    moduleResolution: ts.ModuleResolutionKind.NodeJs,
    allowJs: true,
    noEmit: true,
    esModuleInterop: true,
    allowSyntheticDefaultImports: true,
    strictNullChecks: true,
    noImplicitThis: true,
    noImplicitAny: true,
    strictBindCallApply: true,
    skipLibCheck: true,
    resolveJsonModule: true,
    jsx: ts.JsxEmit.React,
    baseUrl: "src",
});