Making polymorphic component implementations faster
Nashe Omirro
Posted on December 25, 2022
π 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).
Posted on December 25, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 28, 2024
November 21, 2024