Search code examples
reactjstypescriptpolymorphism

creating typesafe polymorphic react components


We're attempting to bring a little more type safety to a bunch of polymorphic react design system components (eg ONLY allow htmlFor prop on label tags).

Below is a contrived example where we have a base polymorphic component, and we want to extend from that component using a slightly different default as property.

import React, {
  ComponentPropsWithoutRef,
  ReactNode,
} from "react";

import { jsx as createComponent } from "@emotion/react";

type AsDomTags = "span" | "div" | 'label';

type StandardComponentWithProps<
  As extends AsDomTags = "span",
  ExtraProps extends {} = {}
> = ComponentPropsWithoutRef<As> & { as?: As } & ExtraProps;

type BodyProps<As extends AsDomTags = "span"> =
  StandardComponentWithProps<
    As,
    {
      children: ReactNode;
    }
  >;

const Body = <As extends AsDomTags = "span">({
  as,
  children,
  ...domAttributes
}: BodyProps<As>) => createComponent(
  as || "span",
  {
    ...domAttributes,
  },
  children
);


// WRAPPED COMPONENT (extends from <Body /> component): ---------------------------------------------------

type TestWrappedComponentProps<As extends AsDomTags = "div"> = BodyProps<As> & { somethingElse?: boolean };

function TestWrappedComponent<As extends AsDomTags = "div">(
  { as, somethingElse, ...props }: TestWrappedComponentProps<As>
) {
  return <Body as={as || 'div'} {...props} />;
}


function App() {
  return (
    <TestWrappedComponent as="label" htmlFor="moo" somethingElse>moo</TestWrappedComponent>
  );
}

Gives a TSC error:

Type '{ as: "div" | As; } & Omit<TestWrappedComponentProps<As>, "as" | "somethingElse">' is not assignable to type 'IntrinsicAttributes & BodyProps<"div" | As>'.
  Type '{ as: "div" | As; } & Omit<TestWrappedComponentProps<As>, "as" | "somethingElse">' is not assignable to type 'IntrinsicAttributes & PropsWithoutRef<ComponentProps<As>> & { as?: "div" | As | undefined; } & { children: ReactNode; }'.
    Type '{ as: "div" | As; } & Omit<TestWrappedComponentProps<As>, "as" | "somethingElse">' is not assignable to type 'PropsWithoutRef<ComponentProps<As>>'.

Playground link

Is anyone able to help me understand what I'm doing wrong here? I feel like this should be possible, just that I'm just missing something. 🤔


Solution

  • Solution

    You can fix the type error by passing the type parameter AsDomTags to the Body component.

    function TestWrappedComponent<As extends AsDomTags = "div">(
      { as, somethingElse, ...props }: TestWrappedComponentProps<As>
    ) {
      return <Body<AsDomTags> as={as || 'div'} {...props} />;
    }
    

    The problem is essentially that the child component is inferred as `Body<As | "div">, and TypeScript isn't able to check that the props passed down are valid. This is partly due to an unsound assumption in the code, and partly due to limitations of the type-checker around resolving complex expressions involving type parameters.

    Equivalently, you can remove the type parameter and just widen the type of the props to AsDomTags.

    function TestWrappedComponent(
      { as, somethingElse, ...props }: TestWrappedComponentProps<AsDomTags>
    ) {
      return <Body as={as||"div"} {...props} />;
    }
    

    It might look like you've lost something in terms of type-specificity by removing the type parameter, but the two versions are equivalent. For reasons I will explain below, the difference in the default value of the type parameter between Body and TestWrappedComponent doesn't actually matter.

    Explanation

    Default Types and Default Values

    Firstly, the validity of the assignment depends on a faulty assumption about default values. There is no necessary connection between the default of the type parameter <As extends AsDomTags = "div">, and the default value of "div" passed down to the Body component in as={as || div}.

    The code seems to assume that As="div" when no value is passed for the as prop. However, because the as prop is nullable, it is possible to provide a concrete type for As, but still omit as. In that case the default value of "div" will not be valid for e.g. If we instantiate the component as TestWrappedComponentProps<"span">, then the type of the prop is as?: "span", so it is still correct to omit as, but incorrect to assign "div" as the default.

    You can see this more directly if you move the default value to the destructuring expression.

    function TestWrappedComponent<As extends AsDomTags = "div">(
      { as="div", somethingElse, ...props }: TestWrappedComponentProps<As>
    ) {
      return <Body as={as} {...props} />;
    }
    

    You will get this error from the function signature:

    Type '"div"' is not assignable to type 'As'.
      '"div"' is assignable to the constraint of type 'As', but 'As' could be instantiated with a different subtype of constraint 'AsDomTags'.
    

    In other words, As might have been instantiated as "span", so it's not necessarily safe to use "div" as the default value.

    Resolving Intersections

    Let's take a look at the last two lines from the error you posted.

    Type '{ as: "div" | As; } & Omit<TestWrappedComponentProps<As>, "as" | "somethingElse">' is not assignable to type 'IntrinsicAttributes & PropsWithoutRef<ComponentProps<As>> & { as?: "div" | As | undefined; } & { children: ReactNode; }'.
        Type '{ as: "div" | As; } & Omit<TestWrappedComponentProps<As>, "as" | "somethingElse">' is not assignable to type 'PropsWithoutRef<ComponentProps<As>>'
    

    The first line says that a certain type is not assignable to a particular intersection type. The next line zooms in on a specific condition of the intersection which is not satisfied. The structure of the error is like this:

    Type 'T' is not assignable to type 'A & B & C'.
    (because)
    Type 'T' is not assignable to type 'B'.
    

    In order to be assignable to the intersection type A & B & C, T must be assignable to each of A, B, and C individually.

    It's interesting to me that the type-checker is resolving that part of BodyProps<As | "div"> as 'PropsWithoutRef<ComponentProps<As>>' instead of 'PropsWithoutRef<ComponentProps<As | "div">>'. It seems like maybe it's collapsing As | "div" to As because "div" is a possible case of As. I'm not 100% sure whether the type-checker is correct to do that, but it makes sense that BodyProps<"div"> isn't assignable to BodyProps<"As"> because As might be "span" or "label".

    Typechecker Limitations

    The typechecker isn't always able to resolve complex expressions involving type parameters, even if they are intuitively equivalent.

    If I modify the StandardComponentWithProps type declaration so that the as prop is no longer optional, and remove the default value, I still get an error:

    // this causes a type error
    function TestWrappedComponent<As extends AsDomTags = "div">(
      { somethingElse, ...props }: TestWrappedComponentProps<As>
    ) {
      return <Body<As> {...props} />;
    }
    

    I still get an error:

    Type 'Omit<TestWrappedComponentProps<As>, "somethingElse">' is not assignable to type 'PropsWithoutRef<ComponentProps<As>>'.
    

    But if I remove somethingElse from the destructuring expression, so that the type of props isn't wrapped in Omit<>, it works just fine.

    // this works
    function TestWrappedComponent<As extends AsDomTags = "div">(
      {   ...props }: TestWrappedComponentProps<As>
    ) {
      return <Body<As> {...props} />;
    }