5

A Modal component must render a Modal.Content, but not necessarily as its immediate child. For example:

Allowed

<Modal>
  <Modal.Content>...</Modal.Content>
</Modal>

Also allowed

<Modal>
  <UserForm>...</UserForm>
</Modal>

where UserForm renders a Modal.Content.

Not allowed

<Modal>
  <UserForm>...</UserForm>
</Modal>

where UserForm doesn't render a Modal.Content.

I'd like to throw a warning if the user didn't use a Modal.Content inside a Modal.

How could I check whether a Modal renders a Modal.Content when the Modal.Content could be a deeply nested child?

Misha Moroshko
  • 166,356
  • 226
  • 505
  • 746

3 Answers3

1

Short answer, no, TS type system is structurally based, not nominally based. It is quite hard to do this in a clean and easy manner.

Longer answer

You can't do this very easily. With some imagination we can achieve this entirely through compile checks. We have to recursively go through the children nodes and check if it is our ModalContent, if we one leaf which satisfies this, then we can return JSX.Elemnt, otherwise return undefined as an indicator to the user that we are using Modal incorrectly

First of all is the biggest complication with JSX, is the implementation that the return type of all JSX components is React.ReactElement<any, any>, but we need the type alias generics to be typed so we can infer the values of the children.

Anytime we put something between <> the result will get inferred to React.ReactElement<any, any>.

I have tried to look around into overriding or declaration merging JSX.Element, to no success. Perhaps someone else may know how to overcome this so we can actually use JSX effectively.

So we'll have to throw out JSX entirely for this to work

Or you must be willing to throw out conventional JSX, we will have to assert certain types on the tree. Specifically only the branch components that lead to the ModalContent leaf/node has to be typed.

I've also switched to Function Components, it's the preferred way of doing React TSX nowadays, and reserve for some cases is almost always easier to type.

There's also limitations with this, I haven't really ensured if it works on Portalling/Fragments/or Lists

I'll describe the supported syntax first

// Works fine on shallow trees
<ModalFC>
  <div></div>
  <div></div>
  <div></div>
  {(<ModalContentFC />) as JSXCast<typeof ModalContentFC>} //Note the casting operator
</ModalFC>
// Have to use utility functions for deeper trees
// Have to cast using `as const` on any children lists (but only sometimes)
<ModalFC>
  <div></div>
  <div></div>
  <div></div>
  {Wrapper(MockDiv, {
      children: (
          [
              <div></div>,
              (<ModalContentFC />) as JSXCast<typeof ModalContentFC>
          ] as const //Necessary to enforce tuple
      )
  })}
</ModalFC>
//Not using JSX, a lot easier to type, but no more JSX :(
ModalFC({
  children: Wrapper(MockDiv, {
      children: Wrapper(MockDiv, {
          children: Wrapper(MockDiv, {
              children: Wrapper(MockDiv, {
                  children: ModalContentFC({})
              })
          })
      })
  })
})

The way this works is by recursing through the children key/value pair of the React Functions, and checking their children, and so on and so forth. We use conditional checking to decide what to do at each point.

type UnionForAny<T> = T extends never ? 'A' : 'B'

// Returns true if type is any, or false for any other type.
type IsStrictlyAny<T> =
  UnionToIntersection<UnionForAny<T>> extends never ? true : false

type IsModalContentPresent<CurrentNode, Tag extends string> = 
    IsStrictlyAny<CurrentNode> extends true 
        ? false
        : CurrentNode extends Array<any>
            ? IsModalContentPresent<CurrentNode[number], Tag>
        : CurrentNode extends Record<string, any> 
            ? unknown extends CurrentNode['type']
                // We aren't a ReactElement, check if we are a ReactPortal or ReactFragment
                ? unknown extends CurrentNode['children']
                    ? CurrentNode extends Iterable<any>
                        // ReactFragment
                        ? IsModalContentPresent<CurrentNode[keyof CurrentNode], Tag>
                        // I'm not sure if this would ever happen
                        : false
                    // ReactPortal
                    : IsModalContentPresent<CurrentNode['children'], Tag>
                // ReactElement
                : CurrentNode['type'] extends Tag
                    ? true
                    : CurrentNode['props'] extends Record<string, any>
                        ? IsModalContentPresent<CurrentNode['props']['children'], Tag>
                        : false
            : false

function ModalFC<
    C extends 
        // Default Component
        ReactElement<P, T> | Readonly<ReactElement<P, T>> |
        // List (I can't imagine supporting other JSX shapes)
        Array<ReactElement<P, T>> | ReadonlyArray<ReactElement<P, T>>,
    P = C extends Array<any> 
        ? C[number]['props']
        : C extends Record<string, any> 
        ? C['props'] 
        : never, 
    T extends string = C extends Array<any> 
        ? C[number]['type']
        : C extends Record<string, any> 
        ? unknown extends C['type'] 
            ? C['defaultName']
            : C['type']
        : never, 
>(props: ModalProps<C>): 
    Extract<IsModalContentPresent<C, 'Modal.Content'>, true> extends never ? undefined : JSX.Element
{
    return null!
}

const ModalContentFC = (props: ContentProps): ReactElement<ContentProps, 'Modal.Content'> => null! //mock return

const Wrapper = <P extends {children: C}, C>(fc: (props: P) => JSX.Element, props: P): ReactElement<P, string> => fc(props)

View this all on TS Playground, I imagine (and know) this has a lot of limitations, and kind of requires you work very hard to know what you are doing with TS, if you want to fix all the edge-cases I probably built in. And to have a some understanding of how the maintainers at React decided to type the framework.

Supplementary reading:

Shorter Answer (Runtime-based)

It is a lot easier to check this at runtime than it is to add compile time support. This is because we don't have to care about types. I imagine the psuedocode is similiar, but instead of checking the "theoretical" tree of possible values, you can just recurse through the actual object tree. Perhaps use the key or some other unique value to mark the ModalContent then if it cannot find it in the deeply nested tree, throw an error when the component tries to mount.

Cody Duong
  • 2,292
  • 4
  • 18
  • Thanks a lot for such a detailed answer. Regarding the runtime-based approach, from what I can see the `children` tree of the `Modal` doesn't contain all the deeply nested components that will be actually rendered. For example, you'll see `UserForm` there but not `Modal.Content` that the `UserForm` is rendering. Could you elaborate a bit on this or maybe create a little runnable example? – Misha Moroshko Apr 08 '22 at 21:15
1

I think this can be done pretty clean with Context, also works when you add another Modal in your Modal and ModelContent exists only in the inner Modal, but not in the outer one:

const ModalContext = createContext({ hasContent: false });

function CheckContentExists() {
    const context = useContext(ModalContext);
    if (!context.hasContent) {
        throw new Error('You must use Modal.Content in Modal');
    }
    return null;
}

function Modal(props) {
    return (<ModalContext.Provider value={{ hasContent: false }}>{props.children}<CheckContentExists/></ModalContext.Provider>);
}

function ThisWillUseModelContent() {
    return (<ModalContent>Deep Modal.Content</ModalContent>)
}

function ModalContent(props) {
    const context = useContext(ModalContext);
    context.hasContent = true;
    return props.children;
}


function NoError() {
    return (
        <Modal><ModalContent>Content</ModalContent></Modal>
    );
}

function StillNoError() {
    return (
        <Modal><ThisWillUseModelContent>DeepContent</ThisWillUseModelContent></Modal>
    );
}

function Error() {
    return (
        <Modal>
            <div>Oops, no ModalContant:(</div>
        </Modal>
    );
}

function TrickyError() {
 return (<Modal>
            <div>No ModalContent in the first Modal, but we have ModalContent deeper in another Modal. It should still throw</div>
            <Modal><ModalContent>This is OK, but the outer Modal is missing ModalContent:(</ModalContent></Modal>
        </Modal>)}

You can even modify this to have exactly only one ModalContent in the Modal tree.

Istvan Tabanyi
  • 743
  • 5
  • 12
0

You can create a function with a recursive reduce to find a nested child Component by name:

function getChildComponentByName(children, componentName) {
  const nodes = Array.isArray(children) ? children : [children];
  return nodes.reduce((modalContent, node) => {
    if (modalContent) return modalContent;
    if (node) {
      if (node.type && node.type.name === componentName) return node;
      if (node.props) return getChildComponentByName(node.props.children, componentName);
    }
  }, null);
}

Then you can use that function in multiples places, one of them could be in propTypes definition. Example:

Modal.propTypes = {
  children: function (props, propName, componentName) {
    if (!getChildComponentByName(props.children, 'ModalContent')) {
      return new Error(
        'Invalid prop `' +
          propName +
          '` supplied to' +
          ' `' +
          componentName +
          '`. Validation failed. Modal Content is required as child element'
      );
    }
  },
};

If ModalContent is not found as child component a warning will be shown in the JavaScript console.

Warning: Failed prop type: Invalid prop children supplied to Modal. Validation failed. Modal Content is required as child element Modal App

See working example

I didn't test all possible scenarios, but this could gives you a clue

lissettdm
  • 12,267
  • 1
  • 18
  • 39