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 />;