Type checking JSX: A can of props

Imagine you’re writing a React application in TypeScript. These days, there are a lot of helpers available, e.g. tsdx, that’ll help you get started with this. The advantage of TypeScript over plain JavaScript is clear: you’ll notice bugs earlier. This is particularly true in the context of writing UI code, which is often much harder to test than the backend of your application. Help by the compiler is great, because it becomes a lot harder to write invalid JSX components.

However, this post is not going to argue about whether or not to prefer testing over types. Instead, I’d like to describe how JSX type checking in TypeScript actually works and the problems you’re going to encounter when implementing custom, non-React JSX components.

Factories

Before we dive into details, let’s recap how compilation of JSX works. The TypeScript compiler has three different modes for dealing with .tsx files, i.e. TypeScript code that contains JSX tags. For the purpose of this post, I’m going to assume the react mode which actually transforms tags into plain JavaScript code. For example, the following TSX snippet:

<tag attr="abc">
    {children}
</tag>

gets turned into:

React.createElement("tag", { attr: "abc" }, children)

The adavantage of this compilation mode is that it requires no further processing by Babel, Webpack or other tools. However, Babel would do exactly the same transformation as the TypeScript compiler, so it doesn’t matter too much which tool does it.

Now let’s take a look at the different parts of the compilation output.

Factory
React.createElement is the factory that produces the actual objects that React uses for rendering. This can be configured in the TypeScript compiler by setting the jsxFactory option.
Element
The first argument passed to the factory is the element. There are three categories of elements: intrinsic elements, function components and class components. The example above contains an intrinsic element that directly corresponds to some HTML tag. Function and class components are user-defined and correspond to React components, which may contain additional logic like state. Intrinsic elements start with a lower-case letter, whereas the others start with an upper-case letter.
Props
The second argument passed to the factory is the props object that contains all attributes of the element. This object is a key-value mapping from strings (the fields of the object) to arbitrary values. In React, the values may be plain strings (e.g. for CSS classes), functions (e.g. for event handlers), or others.
Children
Finally, the factory receives the children of the element. Unfortunately, it is hard to predict what kind of values is passed in here: it can be a (arbitrarily nested) list of other elements or even plain text.

What happens in the factory is defined completely by the implementation. However, it is very hard to assign a type to the factory function because of the wide variety of objects it may receive as element and children. And the story doesn’t even end here.

JSX without React

React has pioneered the JSX syntax extension and thanks to the wide-spread use of React, JSX has become similarly popular. But JSX isn’t bound to React. Other frameworks are free to interpret it in any other way. To quote the draft specification:

JSX is an XML-like syntax extension to ECMAScript without any defined semantics. It’s NOT intended to be implemented by engines or browsers. It’s NOT a proposal to incorporate JSX into the ECMAScript spec itself. It’s intended to be used by various preprocessors (transpilers) to transform these tokens into standard ECMAScript.

This document mentions React only at the end as an example for such a preprocessor.

But what other things could one do with JSX, if not targetting HTML? React Native is one such example, which represents platform-specific widgets (like buttons) as JSX elements. Those can be rendered to iOS, Android[1] and HTML. Another use case would be for implementing a text processor that can generate PDFs. Anything to do with document markup can be expressed very succinctly with XML tags.

Now, let’s take a look at what is required to build your own component library. We’re going to assume that we don’t want to introduce any dependencies to React and tailor the factory process to our requirements.

Types of custom elements

The first thing to decide is what element types to support. Intrinsic elements are usually associated with HTML, so we’re not going to use them. Left are (function and class) components. In React, the latter are traditionally used only when state is involved (although hooks can be used for that now). So, we’re going to focus on function component, as it also simplifies the type checking story.

But before, we need to look at the way TypeScript type checks JSX elements. A naive approach would be as follows:

  1. Expand the tags into factory calls (React.createElement(...)).
  2. Type check the resulting syntax tree.

This is not what TypeScript does. Instead, TypeScript treats JSX elements as special syntax with special typing rules. That is, in TypeScript’s type system, JSX is a real extension of the language and not just syntactic sugar.

Why? I can only speculate. Possibly it is easier to display diagnostics (e.g. errors) when source code is not transformed before type checking.

The TypeScript documentation explains:

As the name suggests, the component is defined as a JavaScript function where its first argument is a props object. TS enforces that its return type must be assignable to JSX.Element.

In my opinion, this is slightly misleading. A function component must – obviously – be a function. However, it may return an arbitrary type (see below for the necessary configuration), because the JSX namespace can be overriden. In fact, this is very useful when constructing a custom syntax tree.

For the remainder of this post, I’m going to use a generic tree representation for arbitrary elements. This Tree class captures name, properties and children of an element virtually unchanged.

export class Tree {
    constructor(
        public readonly name: string,
        public readonly props: Map<string, string>,
        public readonly children: Tree[]) {}

    toJSON() {
        return {
            name: this.name,
            props: Object.fromEntries(this.props.entries()),
            children: this.children
        };
    }
}

Constructing custom function components

Let’s look at a concrete example for a function component now. In this example, the type would be a function from props to Tree. The type of props is used by the TypeScript compiler to figure out which properties the element accepts.

const MyComponent = (props: { count: number, name: string }) =>
    new Tree(
        "mycomponent",
        new Map([["count", props.count.toString()], ["name", props.name]]),
        []
    );

This declaration would tell TypeScript that the following JSX snippet is valid:

<MyComponent count={ 3 } name="hi" />

but these aren’t:

<MyComponent count="3" name="hi" />

<MyComponent count={ 3 } name="hi">
    <SomeChildElement />
</MyComponent>

If we want to accept children, all we have to do is add a children field to props:

const MyComponent = (props: { count: number, name: string, children?: Tree[] }) =>
    new Tree(
        "mycomponent",
        new Map([["count", props.count.toString()], ["name", props.name]]),
        props.children || []
    );

It remains to implement the factory function. Recall that the compilation of JSX doesn’t directly call the function components; instead it passes them to the factory. Unfortunately, this is where things become complicated.

A can of props

Consider again this JSX snippet:

<MyComponent count={ 3 } name="hi">
    <SomeChildElement />
</MyComponent>

The result after compilation is:

MyFactory.createElement(
    MyComponent,
    { count: 3, name: "hi" },
    MyFactory.createElement(SomeChildElement)
)

How can we implement createElement such that it calls the function components with the appropriate parameters? It should be a piece of cake, right?

function createElement<Props>(f: (props: Props) => Tree, p: Props, ...children: Props[]): Tree {
    return f(p);
}

Receive the function component and the props, call the function with the props, done.

But wait, did we just forget about the children? Won’t somebody please think of the children? TypeScript will happily type check this, whereas the runtime will proceed to throw errors left and right. Turns out, TypeScript pretty much ignores the typing of the createElement function once it has type checked the JSX tags. This is what the academics call unsound.[2]

What’s the problem? Even though we have declared children as a field in props, they don’t get passed in as part of that object! In fact, we might not even have a props object! The factory has to deal with that and manipulate the props object accordingly. Here’s the bare minimum implementation:

function createElement(f: (props: any) => Tree, p: any, ...children: Tree[]): Tree {
    let _p = p || {};
    if (children.length > 0)
        _p.children = children;
    return f(_p);
}

Note the presence of all those any types and the absence of elegance.

This is not React, tsc!

If you’re still reading this, congratulations. Luckily we’re almost done warping TypeScript’s reality to our needs. The final trick is to convince TypeScript that we want it to check that the produced trees are of our Tree type. This involves declaring the JSX namespace as mentioned above.

declare global {
    namespace JSX {
        type Element = Tree;

        interface ElementAttributesProperty {
            props: {};
        }
                   
        interface ElementChildrenAttribute {
            children: {};
        }
    }    
}

There are two reasons to do this:

Firstly, if for some reason @types/react is in your node_modules, TypeScript will complain about your JSX code because the Tree type isn’t a subtype of whatever React wants it to be:

error TS2605: JSX element type 'Tree' is not a constructor function for JSX elements.
   Type 'Tree' is missing the following properties from type 'Element': type, key

In my opinion, this is highly anti-modular, because non-imported files may affect type checking.

Secondly, even without any other type definitions interfering, if those interfaces aren’t defined, TypeScript will not type check the children. This means that e.g. the following snippet unexpectedly type checks:

<MyComponent count={ 3 } name="hi">
    invalid plain text
</MyComponent>

Finally, composite components

Another use case for custom JSX that I haven’t mentioned above is when developing a frontend according to the Atomic Design principles. Maybe you want to restrict your expressive power only to the atoms, instead of doing arbitrary things with HTML. But when we want to combine atoms into molecules, organisms or even larger parts, we may want to implement those in terms of the atoms. Just like in React, where we can always refer to other components in our components. Fortunately, that’s as easy as in React:

const MyIndexedList = (props: { list: string[] }) =>
    <MyList>
        {
            props.list.map((item, index) =>
                <MyText value={ `${index}: ${item}` } />
            )
        }
    </MyList>

Conclusion

JSX is a nice, general-purpose syntax for expressing a wide variety of markup formats. But the devil is in the details: if you have the audacity to not use React, you’ll have to work hard to make TypeScript accept your good code and reject your bad code. If you’re brave, feel free to look at the actual React typings. But don’t tell me I didn’t warn you.

  1. React Native itself integrates with React, i.e., React Native components are regular React components. However, embedding HTML elements – a syntactically valid construction – produces runtime errors. Text output must be wrapped in specific elements.  ↩

  2. That‘s not the only unsoundness in TypeScript’s type system. For example, function parameters are bivariant.  ↩

TAGS

Kommentare

Um die Kommentare zu sehen, bitte unserer Cookie Vereinbarung zustimmen. Mehr lesen