4

I have a Modal function component and it should have three child function components Header, Body and Footer and I want to constrain the Modal to only allow elements of type Header | Body | Footer as it's top level child elements.

<Modal>
  <Modal.Header></Modal.Header>
  <Modal.Body></Modal.Body>
  <Modal.Footer></Modal.Footer>
</Modal> 

I have created the function components but I don't know how to constraint them:

import React, { ReactElement, ReactNode } from 'react'

function Header(props: { children: ReactElement }) {
  const { children } = props
  return <>{children}</>
}

function Body(props: { children: ReactElement }) {
  const { children } = props
  return <>{children}</>
}

function Footer(props: { children: ReactElement }) {
  const { children } = props
  return <>{children}</>
}


function Modal(props: { children }) {
  const { children } = props
  return <>{children}</>
}

Modal.Header = Header
Modal.Body = Body
Modal.Footer = Footer

export default Modal

I tried to create a return type for the functions like this (example for Header) but it didn't prevent to pass e.g. a <div/> instead of the Header element:

type Header = ReactElement
function Header(props: { children: ReactElement }): Header => {...}
function Modal(props: { children: Header }): ReactElement => {...}

I've created a TypeScript Playground here.

Alexander Zeitler
  • 11,919
  • 11
  • 81
  • 124

2 Answers2

4

TL;DR: This is currently not possible due to the typing of JSX elements in TypeScript. Read on to get some more detail, if you like.

The culprit

From the TS JSX Docs:

The JSX result type

By default the result of a JSX expression is typed as any. You can customize the type by specifying the JSX.Element interface. However, it is not possible to retrieve type information about the element, attributes or children of the JSX from this interface. It is a black box.

The issue pretty much all other issues I found on the topic (like this one) link to is open since February 2018 and still active.

Down the typing rabbit hole

The react typings define JSX.Element as a React.ReactElement<any, any>. The fact aside that any, any doesn't look promising in the first place, let's see if we can make use of ReactElement in any way to lock down typing any further to help us with typing our children further (we know the answer is no but let's see why that's the case).

Thanks to GitHub's excellent indexing of the React typings, we can easily find the React.ReactElement<P, T> definition here:

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

telling us that the first type parameter is for the props (so that isn't useful unless we wanted to try and type check based on props, see the alternatives paragraph below). The second parameter sends us down a little further; type: T sounds interesting and is constrained to string | JSXElementConstructor<any>. Maybe this gets us further?

Answer is no once more. After finding the definition:

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

it turns out that the any in JSXElementConstructor<any> also stands for some sort of props.

For completeness sake, looking at the the definition of Component we will find that it takes the types of the class component's props and state as type arguments.

I tried to work back from there and type Modal's child with something like

type ModalChild = ReactElement<unknown, new (props: unknown) => MHeader>;

function Modal(props: { children: ModalChild | ModalChild[] }) {
  const { children } = props
  return <>{children}</>
}

however that still somehow allowed spans and divs as children.

There is not much further to go from here so it seems the statement from the JSX docs holds true as well for the specific case of React types.

(Non-)Alternatives

There is a related question on the site which talks about the same issue: How do I restrict the type of React Children in TypeScript, using the newly added support in TypeScript 2.3?. Existing answers suggest typechecking through the props the components take, which however I couldn't get to work (and both high voted answers note that there is no proper typechecking either). I still wanted to mention the idea of this approach for completeness sake.

I am surprised I would ever say this but in this case, Flow has an advantage over TypeScript in that its typings for React (specifically React.Element) take a generic parameter defining the type of the component, thus allowing only specific items. See their docs page on the topic typing their props as

type Props = {
  children: React.ChildrenArray<React.Element<typeof TabBarIOSItem>>,
};

limiting the children of the TabBarIOS to TabBarIOSItems.

geisterfurz007
  • 5,292
  • 5
  • 33
  • 54
-1

Your Modal can iterate over its children and discard any disallowed content and log a warning.

const Header = props => props.children;
const Body = props => props.children;
const Footer = props => props.children;

const Modal = ({ children }) => {
  const content = [];
  React.Children.forEach(children,
    (child) => {
      if ([Header, Body, Footer].includes(child.type))
        content.push(child);
      else
        console.warn(`child elements of type ${child.type} not allowed as content`);
    }
  );
  return content;
};

Modal.Header = Header;
Modal.Body = Body;
Modal.Footer = Footer;
Martin
  • 5,714
  • 2
  • 21
  • 41
  • OP asked about typescript type constraints not runtime constraints. – Karen Grigoryan Nov 17 '21 at 12:43
  • @KarenGrigoryan That isn't really that clear from the question as it is currently worded. – Martin Nov 17 '21 at 13:21
  • If you open the OP provided ts playground link it is more than clear. Also the post is tagged with ts and OP mentions that he tried and failed with return type constraints. Anyways if you have doubts you can always first ask in the comments before posting the answer. – Karen Grigoryan Nov 17 '21 at 13:30