Reputation: 15715
I'd like to take some code that is able to correctly determine when my types are incompatible, and use children
instead of a prop. Here's the baseline that errors correctly:
type Option<T> = {
value: T;
text: string;
}
type SelectProps<T> = {
value: T;
options: Option<T>[];
}
const options = [
{ value: 5, text: 'Five' },
{ value: 10, text: 'Ten'}
];
return <Select value="Text" options={options} />; // ERROR: value isn't type number
However I can't seem to get this to error when I use children:
type OptionProps<T> = {
value: T;
children: string;
}
type SelectProps<T> {
value: T;
children: React.ReactElement<OptionProps<T>>;
}
/* No errors here */
<Select value="Text">
<Option value={5}>Five</Option>
<Option value={10}>Ten</Option>
</Select>
I put together a more complete example in this codesandbox (the code from the sandbox can be found below): https://codesandbox.io/s/throbbing-sun-tg48b - Note how renderA
correctly identifies the error, where as renderB
incorrectly has no errors.
import * as React from 'react';
type OptionA<T> = {
value: T;
text: string;
}
type SelectAProps<T> = {
value: T;
options: OptionA<T>[];
onClick: (value: T) => void;
}
class SelectA<T> extends React.Component<SelectAProps<T>> {
renderOption = (option: OptionA<T>) => {
const { value, text } = option;
const onClick = () => {
this.props.onClick(value)
};
return <div onClick={onClick}>{text}</div>
}
render(): React.ReactNode {
return <div>{this.props.options.map(this.renderOption)}</div>
}
}
type OptionBProps<T> = {
value: T;
children: string;
}
class OptionB<T> extends React.Component<OptionBProps<T>> {}
type SelectBProps<T> = {
value: T;
children: React.ReactElement<OptionBProps<T>>[];
onClick: (value: T) => void;
}
class SelectB<T> extends React.Component<SelectBProps<T>> {
renderOption = (option: OptionB<T>) => {
const { value, children } = option.props;
const onClick = () => {
this.props.onClick(value)
};
return <div onClick={onClick}>{children}</div>
}
render(): React.ReactNode {
return <div>{React.Children.map(this.props.children, this.renderOption)}</div>
}
}
class Main extends React.Component {
onClick(value: string) {
console.log(value);
}
renderA(): React.ReactNode {
const options = [
{ value: 5, text: 'Five' },
{ value: 10, text: 'Ten'}
]
return <SelectA value="Text" options={options} onClick={this.onClick} />
}
renderB(): React.ReactNode {
return (
<SelectB value="Text" onClick={this.onClick}>
<OptionB value={5}>Five</OptionB>
<OptionB value={10}>Ten</OptionB>
</SelectB>
);
}
}
Upvotes: 6
Views: 2155
Reputation: 1054
I think that passing type information down through React.Children.map
is what is causing type inference to get messed up. If you look at the type of this.props.children
in your React.Children.map
you will see that it has the React.ReactElement<OptionBPropts<T>>
type, but also a host of other possible types with any
's.
I would use a render props approach instead.
import * as React from "react";
type OptionA<T> = {
value: T;
text: string;
};
type SelectAProps<T> = {
value: T;
options: OptionA<T>[];
onClick: (value: T) => void;
};
class SelectA<T> extends React.Component<SelectAProps<T>> {
renderOption = (option: OptionA<T>) => {
const { value, text } = option;
const onClick = () => {
this.props.onClick(value);
};
return <div onClick={onClick}>{text}</div>;
};
render(): React.ReactNode {
return <div>{this.props.options.map(this.renderOption)}</div>;
}
}
type OptionBProps<T> = {
value: T;
children: string;
};
type SelectBProps<T> = {
value: T;
children: ({
renderOption
}: {
renderOption: (
option: OptionBProps<T>
) => React.ReactElement<OptionBProps<T>>;
}) => React.ReactElement<OptionBProps<T>>;
onClick: (value: T) => void;
};
class SelectB<T> extends React.Component<SelectBProps<T>> {
renderOption = (option: OptionBProps<T>) => {
const { value, children } = option;
const onClick = () => {
this.props.onClick(value);
};
return <div onClick={onClick}>{children}</div>;
};
render() {
return this.props.children({ renderOption: this.renderOption });
}
}
class Main extends React.Component {
onClick(value: string) {
console.log(value);
}
renderA(): React.ReactNode {
const options = [{ value: 5, text: "Five" }, { value: 10, text: "Ten" }];
return <SelectA value="Text" options={options} onClick={this.onClick} />;
}
renderB(): React.ReactNode {
return (
<SelectB<string> value="Text" onClick={this.onClick}>
{({ renderOption }) => (
<>
{renderOption({ value: 5, children: "Five" })}
{renderOption({ value: 10, children: "Ten" })}
</>
)}
</SelectB>
);
}
}
https://codesandbox.io/embed/thirsty-galois-rgcog
Upvotes: 0
Reputation: 74820
Up to now, unfortunately you cannot enforce type constraints on children
props like illustrated in your example. Before, I actually thought that this kind of type checking would be possible. Being curious, I researched a bit:
TypeScript will convert your JSX code to a vanilla React.createElement
statement via the default JSX factory function. The return type of React.createElement
is JSX.Element
(global JSX namespace), which extends React.ReactElement<any>
.
What that means is, when you render SelectB<T>
, your OptionB
components will be represented as JSX.Element[]
aka React.ReactElement<any>[]
.
Given your props type
type SelectBProps<T> = {
value: T;
children: React.ReactElement<OptionBProps<T>>[];
onClick: (value: T) => void;
}
, TypeScript is fine with comparing ReactElement<OptionBProps<T>>[]
to ReactElement<any>[]
and compiles successfully (given some concrete type for T
).
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.
You can prove this behavior (that JSX expressions resolve to JSX.Element
): Simply replace SelectBProps
by:
type SelectBProps<T> = { ... ; children: number[]; ... };
For each OptionB, you now get the error:
Type 'Element' is not assignable to type 'number'
Since TypeScript 2.8, locally scoped JSX namespaces are supported, which can help to use custom types/type checking for JSX.Element
.
TypeScript docs on locally scoped JSX namespaces
JSX type checking is driven by definitions in a JSX namespace, for instance JSX.Element for the type of a JSX element, and JSX.IntrinsicElements for built-in elements. Before TypeScript 2.8 the JSX namespace was expected to be in the global namespace, and thus only allowing one to be defined in a project. Starting with TypeScript 2.8 the JSX namespace will be looked under the jsxNamespace (e.g. React) allowing for multiple jsx factories in one compilation.
In addition, one quote from this very helpful article:
TypeScript supports locally scoped JSX to be able to support various JSX factory types and proper JSX type checking per factory. While current react types use still global JSX namespace, it’s gonna change in the future.
-
Finally, it may be also reasonable to declare children
types
JSX.Element
from an array of elements.If you still want to have extensive type checking, maybe sticking to your previous solution with props may be a valid idea? At last, children
are just another form of props
and Render Props have established as a solid concept.
While that does not solve your original children
type checking case, I hope to have cleared things up a bit (as for me, it did 😊)!
Cheers
Upvotes: 4