Skip to content

JSX.Element and function component return type assignability #61620

New issue

Have a question about this project? No Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “No Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? No Sign in to your account

Closed
6 tasks done
sufianrhazi opened this issue Apr 26, 2025 · 2 comments
Closed
6 tasks done

JSX.Element and function component return type assignability #61620

sufianrhazi opened this issue Apr 26, 2025 · 2 comments

Comments

@sufianrhazi
Copy link

sufianrhazi commented Apr 26, 2025

🔍 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

⭐ 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

  1. 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 a RenderNode, even when given a component function that returns Promise<RenderNode>
  • JSX evaluates to RenderNode
  1. What shortcomings exist with current approaches?

It's not possible to do this right now.

  1. 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.

sufianrhazi added a commit to sufianrhazi/gooey that referenced this issue Apr 26, 2025
This change is unfortunately necessary until either
microsoft/TypeScript#61620 (a new issue
reported by me) or microsoft/TypeScript#21699
(a longstanding old issue unlikely to be fixed) are resolved.

TypeScript uses the JSX.Element type to determine the type of any
JSX expression. It is not possible for two different JSX expressions to
evaluate to different types.

Gooey supports asynchronous components, **but** in order for this to
work a component must be defined as something that returns a
JSX.Element; so JSX.Element must be extended to become:

    RenderNode | (Promise<RenderNode> & Partial<RenderNode>)

This extension of (Promise<RenderNode> & Partial<RenderNode>) means that
in practice, users can easily call rendernode methods (like `.retain()`) via:

    const jsx = <div />;
    jsx.retain?.();
    // ...
    jsx.release?.();

Which should always work, as the `createElement` jsxFactory function
always returns a `RenderNode`.

It's really unfortunate that the optional chaining operation is needed
here.

Hopefully when either of those issues are fixed, the JSX evaluation type
can be separated from the function component return type.
sufianrhazi added a commit to sufianrhazi/gooey that referenced this issue Apr 26, 2025
Bugfixes and typecheck fixes

Changes since 0.22.0

- FEATURE: new dynMap() and dyn() convenience functions exported
- BREAKING: JSX.Element now returns Partial<RenderNode>, which means if you were calling any RenderNode methods on JSX, you must now call them with optional chaining (i.e.: `jsx.retain?.()`). See microsoft/TypeScript#61620
- BUGFIX: fixed crashes when calculations had changes in their dependencies and were retained/unretained/destroyed in certain sequences
@uhyo
Copy link
Contributor

uhyo commented Apr 27, 2025

TypeScript 5.1 introduced JSX.ElementType. This may be what you are looking for?

@sufianrhazi
Copy link
Author

Oh you're absolutely right, I completely missed this addition. Thank you, I'll close this issue.

No Sign up for free to join this conversation on GitHub. Already have an account? No Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants