Description
🔍 Search Terms
"JSX function component return type", "jsxFactory return type", "function component types", "jsx expression types"
Note: this is related to the longstanding issue #21699 -- this issue would not be an issue if that issue was fixed. However it seems like that issue is prohibitively expensive, so I'm filing this issue as a performant workaround that would allow more flexible JSX types.
✅ Viability Checklist
- This wouldn't be a breaking change in existing TypeScript/JavaScript code
- This wouldn't change the runtime behavior of existing JavaScript code
- This could be implemented without emitting different JS based on the types of the expressions
- This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
- This isn't a request to add a new utility type: https://github.com/microsoft/TypeScript/wiki/No-New-Utility-Types
- This feature would agree with the rest of our Design Goals: https://github.com/Microsoft/TypeScript/wiki/TypeScript-Design-Goals
⭐ Suggestion
I'd like a new JSX
namespace type (similar to ElementClass
) that allows framework authors to relax the constraint where JSX.Element
is used as both for the type produced by evaluating JSX and as a constraint where component functions must return a type assignable to JSX.Element
.
Essentially, I'd like to be able to do this:
// Let's say a "RenderNode" is the type I'd like to be produced by evaluating JSX
type RenderNode = {
retain: () => void;
release: () => void;
// ...more methods...
}
declare namespace JSX {
// The type produced by evaluating a JSX expression
type Element = RenderNode;
// The interface acceptable when a class is passed as a component
type ElementClass = { render(): RenderNode | Promise<RenderNode> | null };
// New! The value that component functions
type ComponentFunctionReturnType = RenderNode | Promise<RenderNode> | null;
}
As long as the jsxFactory
function is guaranteed to return a type assignable to JSX.Element
, then functions should be able to be treated as function components if their return type is assignable to JSX.ComponentFunctionReturnType
.
If omitted, the current behavior should be used where function components must return a type assignable to JSX.Element
.
📃 Motivating Example
I'm the author of a UI library called Gooey that uses JSX. It's unlike React and other frameworks that treat JSX expressions as black boxes. Instead, Gooey exposes methods on the JSX.Element
type.
It does something like this:
// Gooey calls the result of a JSX expression a "RenderNode"
interface RenderNode {
retain(): void;
release(): void;
// ...more methods...
}
namespace JSX {
type Element = RenderNode;
}
For example, here's some code that renders a piece of JSX that is moved to to one of two places in the DOM without recreating the underlying elements:
import Gooey, { calc, field, type Component } from '@srhazi/gooey';
const MyTeleportationComponent: Component = (props, { onMount }) => {
// State for where to place some JSX
const position = field<'left' | 'right'>('left');
// Some JSX that gets relocated without being destroyed/recreated
const jsx = <input type="text" />;
onMount(() => {
jsx.retain(); // Hold onto `jsx`, so it is not destroyed when moved
// Swap position every second
const handle = setInterval(() => {
position.set(position.get() === 'left' ? 'right' : 'left');
}, 1000);
return () => {
clearInterval(handle); // Clean up on unmount
jsx.release(); // Allow `jsx` to be destroyed now
};
});
return (
<div>
<div>Left side: {calc(() => position.get() === 'left' ? jsx : null)}</div>
<div>Right side: {calc(() => position.get() === 'right' ? jsx : null)}</div>
</div>
);
};
This is possible because the JSX.Element
type is RenderNode
, a type that has the .retain()
and .release()
methods on it—and the createElement
factory returns this RenderNode
type.
Gooey also supports asynchronous functions as components. That is to say, you could write something like this:
const MyAsyncComponent = async () => {
const data = await fetch(...);
return <div>{data}</div>;
}
And the component will initially render to nothing until the returned promise is resolved. However this does not typecheck.
The problem
TypeScript enforces that all function components must return a value that is assignable to JSX.Element
, which means these async components will not typecheck correctly.
This MyAsyncComponent
is of type () => Promise<RenderNode>
, which is not assignable to RenderNode
.
It's important to note that the JSX factory (createElement
) function in Gooey always returns a RenderNode
instance, even when given a component function that returns a promise.
If this assignability limitation is eased by setting JSX.Element
to be RenderNode | Promise<RenderNode>
, then a DX problem is introduced: the result of evaluating a JSX expression will now give you a RenderNode | Promise<RenderNode>
. Which means our MyTeleportationComponent
code breaks, since now calling jsx.retain()
will fail typechecking because the .retain()
method does not exist on Promise<RenderNode>
(even though createElement
is guaranteed to give you a RenderNode
).
💻 Use Cases
- What do you want to use this for?
As stated above, I'd like to write a framework where:
- components can be functions that return
Promise<RenderNode>
- components can be functions that return
RenderNode
- The
jsxFactory
function always returns aRenderNode
, even when given a component function that returnsPromise<RenderNode>
- JSX evaluates to
RenderNode
- What shortcomings exist with current approaches?
It's not possible to do this right now.
- What workarounds are you using in the meantime?
For Gooey, I'm thinking about lying for the sake of ergonomics, and making JSX.Element
be the type: RenderNode | (Promise<RenderNode> & Partial<RenderNode>)
This type allows Promise<RenderNode>
to be assigned to it
And it allows users to ergonomically call functions on this type via the optional chaining operator:
const jsx = <div />; // Type: RenderNode | (Promise<RenderNode> & Partial<RenderNode>)
jsx.retain?.(); // Because `jsxFactory` always returns RenderNode, it's guaranteed to exist at runtime so this is safe
// However, the optional chaining call appeases the typechecker, due to the `Partial<RenderNode>` addition
I'm not a big fan of this, as it forces conditionals to exist at runtime for the sake of ergonomics.