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.