How to best type a TypeScript collection of React wrappers

Viewed 1206

in my team's application, we have run into a few cases where it would be nice to be able to dynamically compose component wrappers (HOCs) without having to know all the wrapper interfaces ahead of time (mostly for swapping out context providers when large portions of our component tree are rendered from different host containers).

I'm able to write a simple implementation for this, but getting optimal type safety has been... challenging . In short, I'd like to be able to declare a collection of wrappers such that when any wrapper components are passed into it, the compiler will enforce that each wrapper component gets its correct props type as well.

Here's some code to illustrate the problem. Essentially, my question is how to define WrapperSet.

import * as React from "react";

export const Container: React.FC = () => {
  // these could be any arbitrary wrapper components
  const wrappers: WrapperSet = [
    [WrapperA, { label: "WrapperA" }],
    [WrapperB, { title: "foo" }],
  ];

  const content = <span>Original content</span>;

  return wrap(content, wrappers);
};

const WrapperA: React.FC<{ label: string }> = ({ label, children }) => (
  <>
    <div>WrapperA: {label}</div>
    {children}
  </>
);

const WrapperB: React.FC<{ title: string }> = ({ title, children }) => (
  <>
    <div>WrapperB: {title}</div>
    {children}
  </>
);

function wrap(children: JSX.Element, wrappers: WrapperSet): JSX.Element {
  let content = children;

  wrappers.forEach((wrapper) => {
    const [ComponentType, props] = wrapper;
    content = <ComponentType {...props}>{content}</ComponentType>;
  });

  return content;
}

type WrapperSet = [React.ComponentType, React.PropsWithChildren<{}>][];

If I hover over WrapperSet here, TS tells me type WrapperSet = [any, any][], which is not at all protective.

Some other definitions of WrapperSet I have tried:

type WrapperSet2 = [React.ComponentType<any>, React.PropsWithChildren<any>][] &
  {
    [K in number]: WrapperSet2[K] extends [React.ComponentType<infer P>, any]
      ? [React.ComponentType<P>, React.PropsWithChildren<P>]
      : never;
  };

TS interprets this as type WrapperSet2 = [any, any][] & { [x: number]: [any, any]; }, which is no better.

type Wrapper<P> = [React.ComponentType<P>, React.PropsWithChildren<P>];

type PropsFromComponentType<T extends React.ComponentType<any>> =
  T extends React.ComponentType<infer P> ? P : never;

type WrapperSet3 = [any, any][] & {
  [K in number]: WrapperSet4[K] extends [React.ComponentType<infer C>, any]
    ? Wrapper<PropsFromComponentType<C>>
    : never;
};

TS says type WrapperSet3 = [any, any][] & { [x: number]: Wrapper<unknown>; }.

It seems like there must be some way to tell TS that the outer WrapperSet array supports any kinds of Wrappers, but each Wrapper must be internally consistent, based on a single props type. Or maybe TS doesn't support this kind of expression. FYI, I am using TS 4.3.

Thanks in advance!

2 Answers

You can do it with a combination of types and a utility function that will enforce the props for the given component:

type ComponentAndProps<C extends React.ElementType<any>> = [C, React.ComponentPropsWithRef<C>];
type WrapperSet = ComponentAndProps< React.ElementType<any> >[];

function makeGroup<C extends React.ElementType<any>>(Component: C, props: React.ComponentPropsWithRef<C>): ComponentAndProps<C> {
    return [Component, props];
}

Here's a usage example:

export const Container: React.FC = () => {
    // these could be any arbitrary wrapper components
    const wrappers: WrapperSet = [
        makeGroup(WrapperA, { label: "WrapperA" }),
        makeGroup(WrapperB, { title: "foo" }),
        makeGroup(WrapperB, {}),  // Error: missing 'title' property
    ];
  
    const content = <span>Original content</span>;
  
    return wrap(content, wrappers);
};

The trick is the makeGroup() helper function that allows TypeScript to infer and enforce the props type for the component.

If you just use the tuple notation then the props end up as any and TypeScript can't enforce the props:

    const wrappers2: WrapperSet = [
        [WrapperA, { label: "WrapperA" }],
        [WrapperB, {}]  // BAD - no error, TypeScript can't infer the props type for the component here
    ];

Final note - React has a number of utility types for extracting the props type from a component.

I chose ComponentPropsWithRef just in case you have a component that uses refs, but adjust as necessary:

React.ComponentProps<Component>
React.ComponentPropsWithRef<Component>
React.ComponentPropsWithoutRef<Component>

You can create type util which will accept two components and produce a tuple of wrappers and corresponding props:

import React, { FC, ComponentProps } from "react";

type Wrap<Comps extends FC<any>[]> = {
  [Comp in keyof Comps]: Comps[Comp] extends React.JSXElementConstructor<any>
  ? [Comps[Comp], ComponentProps<Comps[Comp]>]
  : never
}

type WrapperSet = Wrap<[typeof WrapperA, typeof WrapperB]>

export const Container: React.FC = () => {
  // these could be any arbitrary wrapper components
  const wrappers: WrapperSet = [
    [WrapperA, { label: "WrapperA" }],
    [WrapperB, { title: "foo" }],
  ];

  const content = <span>Original content</span>;

  return wrap(content, wrappers);
}; // compiles

But there is still a problem with:

function wrap(children: JSX.Element, wrappers: WrapperSet): JSX.Element {
  let content = children;

  wrappers.forEach((wrapper) => {
    const [ComponentType, props] = wrapper;
    content = <ComponentType {...props}>{content}</ComponentType>;
  });

  return content;
}

Likewise, multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred.

This is why ComponentType expects an intersection of all props and not a union. TS is unable to figure out which prop correspond to each component in dynamic loop. In order to fix it, we need to create extra function:

const iteration = <Comp extends React.JSXElementConstructor<any>>(
  Comp: Comp,
  props: ComponentProps<Comp>,
  content: JSX.Element
) => <Comp {...props} >{content}</Comp>

And the whole code:

import React, { FC, ComponentProps } from "react";

type Wrap<Comps extends FC<any>[]> = {
  [Comp in keyof Comps]: Comps[Comp] extends React.JSXElementConstructor<any>
  ? [Comps[Comp], ComponentProps<Comps[Comp]>]
  : never
}

type WrapperSet = Wrap<[typeof WrapperA, typeof WrapperB]>


export const Container: React.FC = () => {
  // these could be any arbitrary wrapper components
  const wrappers: WrapperSet = [
    [WrapperA, { label: "WrapperA" }],
    [WrapperB, { title: "foo" }],
  ];

  const content = <span>Original content</span>;

  return wrap(content, wrappers);
};

const WrapperA: React.FC<{ label: string }> = ({ label, children }) => (
  <>
    <div>WrapperA: {label}</div>
    {children}
  </>
);

const WrapperB: React.FC<{ title: string }> = ({ title, children }) => (
  <>
    <div>WrapperB: {title}</div>
    {children}
  </>
);

const iteration = <Comp extends React.JSXElementConstructor<any>>(
  Comp: Comp,
  props: ComponentProps<Comp>,
  content: JSX.Element
) => <Comp {...props} >{content}</Comp>

function wrap(children: JSX.Element, wrappers: WrapperSet) {
  let content = children;

  wrappers.forEach((wrapper) => {
    const [ComponentType, props] = wrapper;
    content = iteration(ComponentType, props, content)
  });

  return content
}

Playground

Related