👋 Hey if you're looking to add polymorphic components to your project, why not try react-polymorphed, It's a types-only package I made to help create fast polymorphic components without the hassle. It also solves some common problems like correctly inferring event-listeners, supporting refs, and restricting what the component can polymorph into.
The Problem
what causes typescript to suddenly become sluggish is this one type: ComponentPropsWithRef<T>
. Here's what the type currently is:
type ComponentPropsWithRef<T extends ElementType> = T extends new (
props: infer P
) => Component<any, any>
? PropsWithoutRef<P> & RefAttributes<InstanceType<T>>
: PropsWithRef<ComponentProps<T>>;
And the usual implementation is something like this:
// for a polymorphic button
type Component = <C extends ElementType = "button">(
props: ComponentPropsWithRef<C> & { as?: C }
) => {
// ...
};
Of course there's more to it than that (we haven't made it work with forwardRef yet) but this is essentially what it is.
So right now we are feeding C
to ComponentPropsWithRef<T>
. We do not know what C
is until it is used, we just know that it extends the type ElementType
. Since we don't know what it is yet, ElementType
will be used in ComponentPropsWithRef<C>
instead, the ElementType
can be boiled down to this:
type ElementType = keyof JSX.IntrinsicElements | ComponentType<P>;
Let's focus on the keyof JSX.IntrinsicElements
type... THAT is a union of over 173 TYPES! And we are feeding that to ComponentPropsWithRef
!
ComponentPropsWithRef<ElementType>
BUT that isn't exactly the problem, sure this is what slows down typescript but it's only slowing typescript down because of how the ComponentPropsWithRef<T>
type is structured.
Here's where my understanding becomes mixed with a bit of speculation, So take what I say after this with a pinch of doubt, I am just gonna go ahead and say that this piece of code from ComponentPropsWithRef<T>
is what's causing it to be so slow:
// ...
PropsWithRef<ComponentProps<T>>
It's not really because we are using a union of over 173 types to check what component props are, in fact, if you feed ComponentProps<T>
with ElementType
:
ComponentProps<ElementType> // this results to `any`
you wouldn't get any impedement at all, it's still very fast (explained why later). So if it is not the massive union, nor is it ComponentProps
, then is it PropsWithRef
? Also nope, the type below doesn't cause any significant problem at all:
PropsWithRef<ComponentProps<ElementType>>
The true problem is the combination of being placed inside a conditional and typescript having this behavior of going through each element inside a union, to visualize this let's observe this type:
type A<B extends string | number> = B extends string ? "a" : "b";
type IsA = A<string>; // "a"
type IsB = A<number>; // "b"
type IsAB = A<string | number>; "a" | "b"
In the type IsAB
, It's going through every element in the union and testing each on the conditional, which if we now look at what ComponentPropsWithRef<ElementType>
is doing, it is being computed like this:
| PropsWithRef<ComponentProps<"a">>
| PropsWithRef<ComponentProps<"div">>
| PropsWithRef<ComponentProps<"button">>
| // ... all the other 170+ elements
| PropsWithRef<ComponentProps<FunctionComponent<any>>>;
And if we look at what PropsWithRef<P>
is doing, it is also checking if the props contains string ref or exactly this:
type PropsWithRef<P> = "ref" extends keyof P
? P extends { ref?: infer R | undefined }
? string extends R
? PropsWithoutRef<P> & { ref?: Exclude<R, string> | undefined }
: P
: P
: P;
So now, we are feeding EACH ELEMENT PROP into the type above, which then checks if those props have a ref property, which then transforms the type again to have no string
included in the ref property.
IN the end, we still get any
but in a more costly way.
The Solution
So now that we understand the problem, A naive solution I came up with is to lift PropsWithRef<T>
outside the conditional like so:
type ComponentPropsWithRef<T extends ElementType> = PropsWithRef<
T extends new (props: infer P) => Component<any, any>
? PropsWithoutRef<P> & RefAttributes<InstanceType<T>>
: ComponentProps<T>
>;
This makes it ridiculously fast cause now we aren't doing the checks PropsWithRef<T>
on every element! We first resolve what ComponentProps<T>
is and then do the checks PropsWithRef<T>
does.
But isn't ComponentProps<T>
still like this?:
| ComponentProps<"a">
| ComponentProps<"button">
| // ...
| ComponentProps<FunctionComponent<any>>
Yes it is, but I'm just going to guess that those are just accessing individual properties of JSX.IntrinsicElements
as well as just inferring props from FunctionComponent
, also throw in the assumption that typescript have already cached those values since we always use them when we write react JSX.
But it's still a union of over 173+ different objects, but even then, because we also do ComponentProps<FunctionComponent<any>>
or that class component being any
on the other side of the conditional, the union get's simplified to just any
.
Conclusion
I hope at this point you also got that eureka moment I had when I first realized the problem. Typescript is a wonderful language that, just like it's subset Javascript, also has a lot of quirky behavior (just ask a library maintainer).
Top comments (0)