Skip to content

JSX.Element and function component return type assignability #61620

Closed
@sufianrhazi

Description

@sufianrhazi

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions