TypeScript type for React FunctionComponent that returns exactly one IntrinsicElement

Viewed 1014

I want to create a TypeScript type that guarantees that a React FunctionComponent eventually renders exactly one HTML element. That is, it can't be a string, a number, a ReactFragment, a ReactPortal (unless, I suppose, that portal returns exactly one HTML element), null, or a boolean.

My understanding is that a type for all HTML elements is

type IntrinsicElement = JSX.IntrinsicElements<keyof JSX.IntrinsicElements>;

const anIntrinisicElement: IntrinsicElement = <div />;

// @ts-expect-error
const notAnIntrinsicElement: IntrinsicElement = 'foo';

and indeed this compiles.

So, I would expect the following to work for a FunctionComponent that returns exactly one IntrinsicElement:

type IntrinsicFC<P = unknown> = FC<P> & ((props: P) => IntrinsicElement);

const SuccessFC: IntrinsicFC = () => <div>Goodbye</div>;
const FCWithProps: IntrinsicFC<{ a: number }> = (props: { a: number }) => 
  <div>{props.a}</div>;

// @ts-expect-error
const FragmentFC: IntrinsicFC = () =>
  <>
    <div>Hello</div>
    foo
  </>;

// @ts-expect-error
const NullFC = () => null;

// @ts-expect-error
const TextFC = () => 'foo';

Oddly, this does not compile. None of the // @ts-expect-error components are actually faulty.

If this did work, then I suspect my final type would be:

type IntrinsicFC<P = unknown> = FC<P> & {
  (props: P): IntrinsicElement | IntrinsicFC;
};

const NestedSuccessFC: IntrinsicFC = () => <SuccessFC />;

// @ts-expect-error
const NestedFragmentFC: IntrinsicFC = () => <FragmentFC />; // erroneously valid

So, my question is, why does IntrinsicElement reject string, number, and null, but IntrinsicFC not, and how can I fix it?

Update: I've discovered that ReactFragments, for some reason, successfully compile as IntrinsicElements. This is probably because ReactFragment is defined as {} | ReactNodeList, and {} is just "anything that isn't nullish". On the other hand, ReactFragment does not extend IntrinsicElement, so I am not sure why it can be assigned. That may or may not be the root of the issue.

type IsIntrinsicElementAReactFragment =
  IntrinsicElement extends ReactFragment ? true : false; // true

type IsReactFragmentAnIntrinsicElement = 
  ReactFragment extends IntrinsicElement ? true : false; // false

// erroneously valid
// @ts-expect-error
const fragmentIsNotAnIntrinsicElement: IntrinsicElement = <>foo</>;

// correctly invalid
// @ts-expect-error
const notAnIntrinsicElement3: IntrinsicElement = <TextFC />;

TS Playground

1 Answers

This is very interesting question.SOme time ago, I spent a lot of time digging into react built in types. This is what I have found/understood:

If you use jsx syntax, no way that you will infer some specific types.

Take a look at the type definitions of FC

    interface FunctionComponent<P = {}> {
        (props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null;
        propTypes?: WeakValidationMap<P>;
        contextTypes?: ValidationMap<any>;
        defaultProps?: Partial<P>;
        displayName?: string;
    }

    type VFC<P = {}> = VoidFunctionComponent<P>;

    interface VoidFunctionComponent<P = {}> {
        (props: P, context?: any): ReactElement<any, any> | null;
        propTypes?: WeakValidationMap<P>;
        contextTypes?: ValidationMap<any>;
        defaultProps?: Partial<P>;
        displayName?: string;
    }

As you might have noticed, every FUnctionCOmponent returns ReactElement<any,any>.

This is how Reactlement looks like:

    interface ReactElement<P = any, T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>> {
        type: T;
        props: P;
        key: Key | null;
    }

We are interested in T - type generic argument. It extends JSXElementConstructor

    type JSXElementConstructor<P> =
        | ((props: P) => ReactElement | null)
        | (new (props: P) => Component<P, any>);

JSXElementConstructor is just a recursion. Nothing helpful here.

React native syntax is a way more interesting.

Consider next example:

const foo = React.createElement("a"); // ok

This is where magic happens. Take a look at createElement typings:

    // DOM Elements
    // TODO: generalize this to everything in `keyof ReactHTML`, not just "input"
    function createElement(
        type: "input",
        props?: InputHTMLAttributes<HTMLInputElement> & ClassAttributes<HTMLInputElement> | null,
        ...children: ReactNode[]): DetailedReactHTMLElement<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>;
    function createElement<P extends HTMLAttributes<T>, T extends HTMLElement>(
        type: keyof ReactHTML,
        props?: ClassAttributes<T> & P | null,
        ...children: ReactNode[]): DetailedReactHTMLElement<P, T>;
    function createElement<P extends SVGAttributes<T>, T extends SVGElement>(
        type: keyof ReactSVG,
        props?: ClassAttributes<T> & P | null,
        ...children: ReactNode[]): ReactSVGElement;
    function createElement<P extends DOMAttributes<T>, T extends Element>(
        type: string,
        props?: ClassAttributes<T> & P | null,
        ...children: ReactNode[]): DOMElement<P, T>;

    // Custom components

    function createElement<P extends {}>(
        type: FunctionComponent<P>,
        props?: Attributes & P | null,
        ...children: ReactNode[]): FunctionComponentElement<P>;
    function createElement<P extends {}>(
        type: ClassType<P, ClassicComponent<P, ComponentState>, ClassicComponentClass<P>>,
        props?: ClassAttributes<ClassicComponent<P, ComponentState>> & P | null,
        ...children: ReactNode[]): CElement<P, ClassicComponent<P, ComponentState>>;
    function createElement<P extends {}, T extends Component<P, ComponentState>, C extends ComponentClass<P>>(
        type: ClassType<P, T, C>,
        props?: ClassAttributes<T> & P | null,
        ...children: ReactNode[]): CElement<P, T>;
    function createElement<P extends {}>(
        type: FunctionComponent<P> | ComponentClass<P> | string,
        props?: Attributes & P | null,
        ...children: ReactNode[]): ReactElement<P>;

This function knows everything. Do you want IntrinsicElements? No problem!

const createElement = <T extends keyof JSX.IntrinsicElements>(elem: T) =>
  React.createElement(elem);

const test = createElement("a"); // ok
const test_ = createElement(null); // error

Type, you are interested int is : DetailedReactHTMLElement<HTMLAttributes<HTMLElement>, HTMLElement>

You can find it inside createElement overloads.

Feel free to experiment.

Some time ago I wrote two articles about this topic. You can find them in my blog:

Typing react children

Typing react return type

Not strictly related but still might be interesting for you: Typing react props

I'm not sure I have answered your question but I hope you will find my answer helpful

UPDATE

You can also override FUnctionComponent interface, it partially helps but still allows React.Fragment:

interface FunctionComponent<P = {}> {
  (props: PropsWithChildren<P>, context?: any): React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>

}

// ok
const Foo: FunctionComponent<{ label: string }> = (props) => {
  return <div></div>
}

// error
const Bar: FunctionComponent<{ label: string }> = (props) => {
  return 42
}

// error
const Baz: FunctionComponent<{ label: string }> = (props) => {
  return null
}

// error
const Bat: FunctionComponent<{ label: string }> = (props) => {
  return 'str'
}

const x = React.Fragment
// no error
const Fragment: FunctionComponent<{ label: string }> = (props) => {
  return <></>
}
Related