Photo of François

François Wouts

The developer happiness engineer

I write about software engineering, developer productivity and my journey from working at Google to becoming an indie dev.

Follow me on TwitterAbout meRead other articles

Detecting UI components with TypeScript Compiler API

April 2022
Inside Preview.js series

This is part of a series of articles on the internal architecture of Preview.js, a developer tool that helps developers preview any UI component in their React, Vue or SolidJS codebase (with more frameworks to come).

Become a GitHub sponsor to support my writing and receive new articles early!

Today, we're talking about how Preview.js uses static analysis to detect components in a file without any false positives. Here is an example:

Code sample

As a developer looking at this source code, we know that Foo is a component because it's a function that returns a JSX element. We see that Bar is assigned the value of Foo, therefore it's a component too. On the other hand, greeting and Baz are not components.

How can this be detected programmatically? Let's start with the wrong answers.

Regular expressions

You could try to come up with a creative regular expression that matches the particular pattern we're seeing for the definition of Foo. You'd try to match the () => to know that it's a function definition, as well as the return < to know that it returns a JSX element.

However, you'll encounter a number of hurdles:

  • How do you know where the definition of Foo ends, especially if there are nested curly braces?
  • What if a different syntax was used such as function Foo() ...?
  • What about Bar = Foo, especially if Foo is defined in another file?

Regular expressions are great, but they are notoriously bad for parsing and analysing code. Don't do it.

Running the code

A far more reasonable approach would be to execute the code. Import it as a module, look at each exported value and check if it's a function. Then, invoke the function and see if it returns a React element.

Simple, right?

Except there are a number of cases where this won't work. Consider the following example:

export const Foo = (props) => {
  if (!props.label) {
    throw new Error(`Missing "label" prop.`);
  }
  return <div>{props.label}</div>;
};

Invoking this function without the correct arguments would throw an error, and you wouldn't know that it does return a React element when invoked correctly.

How do you find out what arguments to pass to this function? You can't, at least not with the current state of JavaScript tooling.

Analysing the AST

We talked before about regular expressions, which aren't the right tool for this job. Instead, we can parse the code into an AST (abstract syntax tree) which is a structured representation of the code.

This can be done with a number of libraries, such as Acorn or the Babel parser:

const babelParser = require("@babel/parser");

const code = `
const Foo = () => {
  return <div>{greeting}</div>;
};

const greeting = "Hello, World!";

export const Bar = Foo;

export const Baz = () => 123;
`;
const ast = babelParser.parse(code, {
  sourceType: "module",
  plugins: ["jsx"],
});

// File
// -> Program
//    -> VariableDeclaration
//      -> VariableDeclarator
//        -> Identifier: Foo
//        -> ArrowFunctionExpression
//          -> BlockStatement
//            -> ReturnStatement
//              -> JSXElement
//    -> VariableDeclaration
//      -> VariableDeclarator
//        -> Identifier: greeting
//        -> StringLiteral: "Hello, World!"
//    -> ExportNamedDeclaration
//      -> VariableDeclarator
//        -> Identifier: Bar
//        -> Identifier: Foo
//    -> ExportNamedDeclaration
//      -> VariableDeclarator
//        -> Identifier: Baz
//        -> ArrowFunctionExpression
//          -> NumericLiteral: 123

If you're keen to run this yourself, you can explore this AST online.

Thanks to clearly identified node types, we can design an algorithm that walks through the tree and, for a given VariableDeclarator, checks whether its assigned value is an ArrowFunctionExpression that contains a JSXElement. See a full example.

This works perfectly to detect the Foo component. We need a little extra logic to detect Bar, but that can be achieved as well with a little extra work.

There are limitations to this approach however. To start with, we need to cover all possible syntaxes. For example, a function can also be defined with a FunctionDeclaration node, which corresponds to the older function() {} syntax. Additionally, if Foo were declared in a different file, we'd need to support import statements and parse multiple files to support detecting Bar = Foo.

Enter the TypeScript Compiler API

Thankfully, what we're trying to do isn't particularly novel. It's the type of work that TypeScript does internally to analyse the type of variables and validate type constraints at the scale of an entire codebase.

Better yet, the typescript package exposes an API that we can leverage to our advantage. I've written before about its parsing capabilities, but today we're looking more specifically at TypeScript's TypeChecker. Incidentally, it also works with JavaScript and JSX files.

Once we've parsed the AST, this time with TypeScript's own parser, TypeChecker lets us ask "what is the type of this AST node?" with the getTypeAtLocation() method. When this method is invoked, the mighty powers of TypeScript's static analysis are invoked (if you're curious, here's the slightly terrifying implementation in the TypeScript repo).

In our case, we want to know:

  • Is this a function, i.e. does this type have call signatures?
  • Does this function return JSX, i.e. is the return type Element?

This can be done as follows (you can also find it on GitHub):

const ts = require("typescript");

// Path of the file we want to analyse.
// It's important that @types/react is installed in the same package.
const filePath = "example.jsx";

// Make sure to analyze .js/.jsx files.
const options = {
  allowJs: true,
  jsx: "preserve",
};

// Create a TypeScript compilation environment.
const host = ts.createCompilerHost(options);

// Parse and analyse our file, along with dependencies.
const program = ts.createProgram([filePath], options, host);
const sourceFile = program.getSourceFile(filePath);
const checker = program.getTypeChecker();

const detectedComponents = [];
for (const statement of sourceFile.statements) {
  if (ts.isVariableStatement(statement)) {
    for (const declaration of statement.declarationList.declarations) {
      // 🚀 This is where the magic happens.
      const type = checker.getTypeAtLocation(declaration.name);

      // A type that has call signatures is a function type.
      for (const callSignature of type.getCallSignatures()) {
        const returnType = callSignature.getReturnType();
        if (returnType.symbol?.getEscapedName().toString() === "Element") {
          detectedComponents.push(declaration.name.text);
        }
      }
    }
  }
}

console.log(detectedComponents);
// ["Foo", "Bar"]

The beauty of this approach is that Bar (which is defined as Bar = Foo) is detected without any effort on our part. That's because the TypeChecker analysed Bar, figured out that it has the same type as Foo, then gave us the type of Foo, which indeed is a component.

How it works in Preview.js

As you may have guessed already, Preview.js uses the TypeScript Compiler API approach. Since it's open source, you can check out the entire code yourself.

Note that the implementation differs slightly for each framework that Preview.js supports. For example with SolidJS, we're looking for the types Element or FunctionElement in the returned type. In React, we also need to look for class components. A similar approach is used to detect Storybook stories.

Extracting props types

Preview.js also makes use of the TypeScript Compiler API to detect a component's declared properties type, so that it can automatically generate valid props.

For example, say you defined the following component in TypeScript:

const Foo = (props: { label: string; disabled?: boolean }) => {
  return <button disabled={props.disabled}>{props.label}</button>;
};

Preview.js will extract the type of props, figure out that it's an object with a required label property of type string and an optional disabled property, and generate a valid value such as { label: "label" }.

If you'd like to see an article about that, let me know! If your budget allows it, you could also consider becoming a sponsor.

That's a wrap

Did you learn something from this article? I'd appreciate if you shared it around 💙

If you're a frontend dev yourself, try Preview.js today and send me your feedback!

Sign up to my blog

I send out a new article every few weeks. No spam.