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>>'.
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. 🤔
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.
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.
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"
.
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} />;
}