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.
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
For example, the following TSX snippet:
gets turned into:
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.
React.createElementis the factory that produces the actual objects that React uses for rendering. This can be configured in the TypeScript compiler by setting the
- 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.
- The second argument passed to the factory is the
propsobject 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.
- 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 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:
- Expand the tags into factory calls (
- 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:
propsobject. TS enforces that its return type must be assignable to
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.
Tree class captures name, properties and children of an element virtually unchanged.
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
The type of
props is used by the TypeScript compiler to figure out which properties the element accepts.
This declaration would tell TypeScript that the following JSX snippet is valid:
but these aren’t:
If we want to accept children, all we have to do is add a
children field to
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:
The result after compilation is:
How can we implement
createElement such that it calls the function components with the appropriate parameters?
It should be a piece of cake, right?
Receive the function component and the
props, call the function with the
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.
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
The factory has to deal with that and manipulate the
props object accordingly.
Here’s the bare minimum implementation:
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
This involves declaring the
JSX namespace as mentioned above.
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:
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:
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:
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.
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. ↩
That‘s not the only unsoundness in TypeScript’s type system. For example, function parameters are bivariant. ↩