Reputation: 2316
I'm trying to define a wrapper React component that accepts a number of predefined properties. The presence and value of one of the properties (tag
) should also determine if additional properties are available. The react component will act as a wrapper for the provided tag
component, which will be rendered with any props that aren't handled specifically by the wrapping component. If tag
is omitted it would be ok for the wrapper to still accept and forward the unhandled props to the child component, but without providing any types (a simple Record<string, any>
or similar would do).
Unfortunately I haven't found a way to prevent Typescript from generating a union of all possible attributes when the tag
prop is omitted.
A minimal reproduction of the issue is available on Codesandbox. The issue I'm trying to solve is further explained in it, but TL;DR:
<Field tag="textarea" name="test" cols={10} /> // cols is correctly typed
<Field tag="input" name="test" cols={10} /> // cols is correctly marked as invalid
<Field name="omitted" cols={10} /> // cols is typed here even though it's not a prop on <input>
Excerpt of the code:
import * as React from "react";
import * as ReactDOM from "react-dom";
export type FieldTagType = React.ComponentType | "input" | "select" | "textarea";
type TagProps<Tag> = Tag extends undefined ? {}
: Tag extends React.ComponentType<infer P> ? P
: Tag extends keyof JSX.IntrinsicElements ? JSX.IntrinsicElements[Tag]
: never;
export type FieldProps<Tag extends FieldTagType> = {
/** Field name */
name: string;
/** Element/component to createElement(), defaults to 'input' */
tag?: Tag;
};
function Field<Tag extends FieldTagType>({
name,
tag,
...props
}: FieldProps<Tag> & TagProps<Tag>) {
return React.createElement(tag || "input", {
name,
id: name + "-id",
placeholder: name,
...props
} as any);
}
const rootElement = document.getElementById("root");
ReactDOM.render(
<React.StrictMode>
{/*
<textarea> supports the `cols` attribute, as expected.
*/}
<Field tag="textarea" name="textarea" cols={10} />
{/*
<input> doesn't support the `cols` attribute, and
since `tag` is properly set typescript will warn about it.
*/}
<Field tag="input" name="test" cols={10} />
{/*
When `tag` is omitted it appears that FieldTagType
is expanded into every possible type, rather than falling
back to not providing any additional properties.
This results in `cols` being included in the list of defined props,
even though it is not supported by the `input` being rendered as fallback.
Is it possible to modify the typings of TagProps/FieldProps/Field
so that omitting `tag` will not include any additional properties,
but also not error?
Eg. TagProps are expanded to `Record<string, any>`
*/}
<Field name="omitted" cols={10} />
</React.StrictMode>,
rootElement
);
Upvotes: 1
Views: 414
Reputation: 328272
When you call Field
with no tag
property in its argument, this does not cause the compiler to infer "input"
or even undefined
for the Tag
generic parameter. Since tag
is an optional property, an undefined value for tag
can apply to any valid choice of Tag
. For example, you could specify Tag
as "textarea"
and still leave the tag
property out:
Field<"textarea">({ name: "xyz", cols: 10 }); // no error
It's unlikely for that to actually happen, of course. Anyway when you leave out tag
the compiler has no idea what Tag
should be, and just defaults it to its constraint, FieldTagType
.
If you would like it to default to "input"
instead, you can set a generic parameter default:
function Field<Tag extends FieldTagType = input>({
// default here ----------------------> ~~~~~~~
name,
tag,
...props
}: FieldProps<Tag> & TagProps<Tag>) {
return React.createElement(tag || "input", {
name,
id: name + "-id",
placeholder: name,
...props
} as any);
}
That doesn't affect the inference for your other cases, but when tag
is omitted, now you get the empty TagProps<undefined>
for props
, and the desired error:
<Field name="omitted" cols={10} /> // error!
// -----------------→ ~~~~
// Property 'cols' does not exist on type
// 'FieldProps<"input"> & TagProps<"input">'
Upvotes: 1