mogelbrod
mogelbrod

Reputation: 2316

Inferring individual types when using union type generics (extending react component props)

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

Answers (1)

jcalz
jcalz

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">'

Playground link to code

Upvotes: 1

Related Questions