Constraint child elements - reactjs

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.

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.

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;

Related

How to check whether a component renders a deeply nested child of a certain type?

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

React component in TypeScript if passed as parameter and assigned to variable; TS2604: JSX element type does not have any construct or call signatures

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

Wrapper function for JSX elements, typed via generics

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>.

Currying styled Template Literal function

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.

Strictly typing React higher order component which consumes properties with TypeScript

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.

Resources