A new feature, described as “decoupled type-checking between JSX elements and JSX tag types”, arrives with TypeScript 5.1. This feature enables libraries to control what types are used for JSX elements. In this article, I’ll discuss why this matters and how this new feature works.
Jump ahead:
Until version 5.1, TypeScript did an imperfect job of representing what is possible with JSX. The JSX decoupled type-checking feature allows libraries to do a better job of that, by handing them control of JSX type definitions.
It’s probably worth noting that JSX decoupled type-checking is a complicated feature. If you don’t understand it, that’s okay! I’ll confess that as the author of this post, I had to work quite hard to fully comprehend it.
This is a low-level feature that is only likely to be used by library and type definition authors. It’s a primitive that will unlock possibilities for those who are writing JSX without requiring any extra action on their part. In some cases, people writing JSX may not even notice that things have changed for the better.
TypeScript creates a type system that sits on top of JavaScript and provides static typing capabilities. As TypeScript has grown more sophisticated, it’s been able to get closer and closer to representing the full range of possibilities that JavaScript offers.
An example of this evolution was the introduction of union types. If you remember the early days of TypeScript, you’ll recall a time before union types. Back then, we had to use any
to represent a value that could be one of a number of types. Union types solved this imperfect representation of JavaScript:
-function printStringOrNumber(stringOrNumber: any) { +function printStringOrNumber(stringOrNumber: string | number) { console.log(stringOrNumber); }
The problem we’re looking at in this article is in the same vein, but it specifically applies to JSX — which is widely used in libraries, like React. Prior to v5.1, TypeScript lacked the ability to accurately represent all JSX possibilities. This is because the type of JSX element returned from a function component was always JSX.Element | null
. This is a type that is defined in the TypeScript compiler; it cannot be changed by a library author.
Let’s take a look at a simple example to see how this plays out. Say we have a function component that returns a number. We might write something like this:
function ComponentThatReturnsANumber() { return 42; } <ComponentThatReturnsANumber />;
The above code is legitimate JSX, but it is not legitimate TypeScript. As a result, the TypeScript compiler will complain:
You can view this in the TypeScript Playground. The error is thrown because, according to TypeScript, function components that return anything except JSX.Element | null
are not allowed as element types in React.
However, in React, function components can return a ReactNode
. This type includes number | string | Iterable<ReactNode> | undefined
and will likely also include Promise<ReactNode>(
in the future.
As an aside, a return value of number
would be perfectly fine in class components since the restrictions are different there. I spoke to Sebastian Silbermann, who wrote the PR requesting the new feature, about this and he said:
“An interesting note is that before function components we did have full control. Due to
ElementClass
, class components already could returnReactNode
at the type level. It was just function components that were missing full control (or any other component types Suspense or Profiler).”
So here’s the crux of the problem: it is not possible to represent in TypeScript today what is actually possible in React (or in other JSX libraries). Furthermore, what’s returned from JSX may change over time, and TypeScript needs to be able to represent that.
JSX.ElementType
In an effort to address the issue described in the previous section, Sebastian opened a pull request to TypeScript: “RFC: Consult new JSX.ElementType for valid JSX element types“. In that PR, Sebastian explained the issue and proposed a solution — introducing a new type, JSX.ElementType
.
Here’s an illustration that helps explain what the JSX.ElementType
is compared to a JSX element:
// <Component /> // ^^^^^^^^^ JSX element type // ^^^^^^^^^^^^^ JSX element
The significance of JSX.ElementType
is that it is used to represent a JSX element’s type and to allow library authors to control what types are used for JSX elements. This control was not previously available.
The TypeScript pull request was merged, so Sebastian (who helps maintain the React type definitions) exercised new powers in this pull request to the DefinitelyTyped repository for the React type definitions. At the time of writing, this pull request is still open, but once merged and shipped the React community we will feel its benefits.
The changes associated with this new feature are subtle; you can see in this pull request that ReactElement | null
is generally replaced with ReactNode
:
type JSXElementConstructor<P> = - | ((props: P) => ReactElement<any, any> | null) + | ((props: P) => ReactNode) | (new (props: P) => Component<any, any>);
Remember how we mentioned earlier that function components couldn’t return numbers? Let’s look at the updated tests in the PR:
const ReturnNumber = () => 0xeac1; + const FCNumber: React.FC = ReturnNumber; class RenderNumber extends React.Component { render() { return 0xeac1; } }
With this change, React components that return numbers are now valid JSX elements. This is because JSX.ElementType
is now ReactNode
, which includes numbers. New things are possible as a consequence of this change. The library and type definition author now has more control over what is possible in JSX.
To quote Sebastian again, “Now we have control over any potential component type.”
Let’s take another look at our component that produces a number:
function ComponentThatReturnsANumber() { return 42; } <ComponentThatReturnsANumber />;
With Sebastian’s changes, this becomes valid TypeScript. And as React and other JSX libraries evolve, TypeScript compatibility will evolve as well.
The TL;DR of this post is that TypeScript will better allow for the modeling of JSX in TypeScript 5.1. I’m indebted to Sebastian Silbermann and Daniel Rosenwasser for their explanations of the decoupled type-checking between JSX elements and JSX tag types feature.
A special thanks to Sebastian for implementing this feature and for reviewing this article. I hope this post helps improve your understanding of this new TypeScript feature.
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]