Making polymorphic component implementations faster
Tags: webdev, javascript, react
Published: 2022-12-25 07:34:42

👋 Hey if you're looking to add polymorphic components to your project, why not try [react-polymorphed](https://www.npmjs.com/package/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:

```ts
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:
```tsx
// 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:

```tsx
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`!

```tsx
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:

```tsx
// ...
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`: 
```tsx
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:

```tsx
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:
```ts
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:

```tsx
  | 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:

```ts
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:

```tsx
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?:
```tsx
  | 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).