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:
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.
- 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 thejsxFactory
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:
- Expand the tags into factory calls (
React.createElement(...)
). - 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 toJSX.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.
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.
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 props
:
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 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:
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.
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:
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.
-
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. ↩