DEV Community

Cover image for Making polymorphic component implementations faster
Nashe Omirro
Nashe Omirro

Posted on • Edited on

Making polymorphic component implementations faster

👋 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>>;
Enter fullscreen mode Exit fullscreen mode

And the usual implementation is something like this:

// for a polymorphic button
type Component = <C extends ElementType = "button">(
  props: ComponentPropsWithRef<C> & { as?: C }
) => {
  // ...
};
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>>
Enter fullscreen mode Exit fullscreen mode

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`
Enter fullscreen mode Exit fullscreen mode

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>>
Enter fullscreen mode Exit fullscreen mode

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"

Enter fullscreen mode Exit fullscreen mode

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>>>;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
>;
Enter fullscreen mode Exit fullscreen mode

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>>
Enter fullscreen mode Exit fullscreen mode

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)