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?
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:
Contextual Typing
Type Parameters
Conditional Types
Recursive Type References
When to use JSX.Element vs ReactNode vs ReactElement?
as const Assertions
as, or Type Assertions
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.
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.
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
I'm trying to hanlde props of passed React Element in Factory but I cannot because I receive typescript error:
TS2604: JSX element type 'this.extraBlock' does not have any construct or call signatures.
My Child component:
interface BlockTitleType {
title: string,
children?: React.ReactNode
}
const MainContactBlock: React.FC<BlockTitleType> = (e: BlockTitleType) => <div>{e.title}</div>;
My Parent component:
const factory = new TabBlockFactory(MainContactBlock);
const ContactBlocks: React.FC = () => <>factory.createBlock('test title')}</>
My factory:
interface BlockType {
title: string
}
class TabBlockFactory {
private mainBlock: React.ReactNode;
constructor(mainBloc: React.FC) {
this.mainBlock = mainBloc;
}
createBlock = ({title}: BlockType) => {
// the error occurs here:
// TS2604: JSX element type 'this.extraBlock' does not have any construct or call signatures.
return <this.mainBlock title={title}/>
}
}
it works only with any type, but it's an antipatern :(
Update:
I also tried types as React.Component, React.ReactElement, JSX.Elements
I've tried all 3 fixes from #Yuval:
Has no effect - renamed class variable this.mainBlock -> this.MainBlock;
Has no effect - introduced intermediate variable
const Component = this.mainBloc;
return <Component title={title} />;
Successfully helped me - private MainBlock: React.ComponentType<BlockTitleType>; as #Yuval proposed.
TLDR: working sandbox link
So there is a few problems with your code:
you are using the type React.ReactNode to represent a component, and it doesn't work. I recommend that to represent a React Component you use React.ComponentType<PROPS>.
So in your case in will be ComponentType<BlockTitleType>
the line <this.mainBlock /> is problematic, react doesn't like Components that do not start with an Upper case and also its an access to a this attribute at the same time, so separate that into 2 lines like so:
const Component = this.mainBloc;
return <Component title={title} />;
other than that I added some small syntax fixes and small improvements
quick assignment and declaration in the Factory class
missing { in ContactBlocks
Currently this function works fine
function wrapElement(elem: JSX.Element) {
return ({ ...props }) => React.cloneElement(elem, { ...props })
}
I use it like this, because this way I can get intelliSense for tailwind classes
const Btn = wrapElement(<button className="[A LOT OF TAILWIND UTILITY CLASSES]" />)
But I'm trying to get it to return same type as it receives, so I could get intelliSense for attributes on intrinsic HTML elements.
Right now inferred type is
function wrapElement(elem: JSX.Element): ({ ...props }: {
[x: string]: any;
}) => React.FunctionComponentElement<any>.FunctionComponentElement<any>
I tried some stuff and it all failed with all kinds of errors, at this point it feels like this could be to hacky, but maybe I don't understand something?
It is basically impossible to get the correct props from a JSX.Element. You can achieve the design that you want, but you should pass in the element name and the props as separate arguments rather than passing in a JSX.Element.
This code can accept an element name like 'button' or any React component. It returns a function component with the same props. I am not dropping any props from the returned component because it seems like you are using this for setting defaults rather than dropping requirements.
import React, { ComponentType, ComponentProps } from "react";
const wrapElement = <
C extends keyof JSX.IntrinsicElements | ComponentType<any>
>(
Component: C,
presetProps: Partial<ComponentProps<C>>
) => (props: ComponentProps<C>) => {
const merged: ComponentProps<C> = { ...presetProps, ...props };
return <Component {...merged} />;
};
const Btn = wrapElement("button", {
className: "[A LOT OF TAILWIND UTILITY CLASSES]"
});
const Dbl = wrapElement(Btn, { onClick: () => alert("clicked") });
const Test = () => {
return <Dbl>Click</Dbl>;
};
Typescript Playground Link
You might want to customize your merge behavior to combine className or style properties rather than overriding them.
Note: when I tried to merge the props inline like <Component {...presetProps} {...props} /> I got a weird error "Type Partial<ComponentProps<C>> & ComponentProps<C> is not assignable to type IntrinsicAttributes & LibraryManagedAttributes<C, any>." So that is why I am merging the props on a separate line and annotating the type as ComponentProps<C> instead of the inferred type Partial<ComponentProps<C>> & ComponentProps<C>.
Im trying to curry the styled function but react seems to throwing an error at run time. (JSX element does not have any constructor or call signatures)
The idea is to create a base style that can be reused (without setting the element its attached to).
const template = baseStyle`
backgroundColor: reg
`
const Component1 = template(div)
const Component2 = template(a)
The function accepts an html or styled component. This is what i've done so far, im pretty sure the types are off.
export const baseStyle = (strings: TemplateStringsArray, ...rest: Interpolation<any>[]) => {
return (element: AnyStyledComponent): StyledComponent<any, any, any, any> => {
return styled(element)(strings, ...rest)
}
}
Is there a way to do this?
What you are trying to do here is tough because the types for styled-components are really complex. Right now your element argument is AnyStyledComponent which is an element that's already styled.
This type seems to work, but note that styles from the template override those from an already-styled component:
export const baseStyle = (
strings: TemplateStringsArray,
...rest: Interpolation<any>[]
) => {
return <C extends keyof JSX.IntrinsicElements | React.ComponentType<any>>(element: C): StyledComponent<C, any, {}, never> => {
return styled(element)(strings, ...rest);
};
};
You might have better luck using the css utility.
I'm trying to type some higher order React components which are more complex than only injecting properties. These HOCs must be able to:
receive properties that are not (or only optionally) forwarded to the wrapped component
provide some new properties to the wrapped component
take additional arguments when they are created
From what I can tell, this means we must have three types:
The properties the HOC provides to the wrapped component
The properties the HOC uses, but which the wrapped component not necessarily cares about
The properties of the wrapped component that the HOC doesn't care about
I have the following code that works, but I can't find a way to avoid the any cast when passing the props on to the wrapped component:
import React, { ComponentType } from 'react';
// Props of the wrapped component without the props provided by the HOC
type StrictWrappedProps<WrappedProps, InjectedProps> = Omit<
WrappedProps,
keyof InjectedProps
>;
type WrappedComponent<WrappedProps, InjectedProps> = ComponentType<
WrappedProps & InjectedProps
>;
type HOCType<WrappedProps, InjectedProps, OwnProps> = ComponentType<
OwnProps & StrictWrappedProps<WrappedProps, InjectedProps>
>;
type LengthifyOwnProps = {
name: string;
};
type LengthifyInjectedProps = {
length: number;
};
export const lengthify = <WrappedProps extends {}>(
Wrapped: WrappedComponent<WrappedProps, LengthifyInjectedProps>,
multiplier: number
): HOCType<WrappedProps, LengthifyInjectedProps, LengthifyOwnProps> => ({
name,
...props
}) => {
const length = name.length * multiplier;
return <Wrapped {...props as any} length={length} />;
};
Here is an example usage of that HOC:
const myComp = ({ bool, length }: { bool: boolean; length: number }) => (
<p>
Bool is: {bool}. Length is: {length}
</p>
);
const EnhancedMyComp = lengthify(myComp, 2);
const usage = () => <EnhancedMyComp bool={true} name="Hello" />;
The any cast is a small workaround, but I would like to find the strict way of typing this.
The React TypeScript cheat sheet on HOCs links to this bug in the TypeScript repo, but it seems to be more or less solved, and only relates to HOCs that inject properties.
Other questions here on StackOverflow also seem to to be about injecting properties, or confusion about the ComponentType type.