Currying React components in TypeScript

captainyossarian

yossarian

Posted on July 20, 2021

Currying React components in TypeScript

Cover image made by Victoria Smith

First of all, let me remind you what currying actually means.

const add = (x: number) => (y: number) => x + y;
const result = add(4)(2) // 6
Enter fullscreen mode Exit fullscreen mode

This is all what you need to know for this moment.
Let's get straight to the point.

Consider this example:

import React, { FC } from "react";

/**
 * Converts 
 * ['hello', 'holla', 'hi']
 * into
 * {hello: 0, holla: 1, hi: 2}
 * 
 */
type ToRecord<
    T extends string[],
    Cache extends Record<string, number> = {}
    > =
    T extends []
    ? Cache
    : T extends [...infer Head, infer Last]
    ? Last extends string
    ? Head extends string[]
    ? ToRecord<
        Head, Cache & Record<Last, Head['length']>
    >
    : never
    : never
    : never

const Curry = <
    Elem extends string,
    Data extends Elem[]
>(data: [...Data]): FC<ToRecord<Data>> =>
    (props) =>
        <div>{Object.keys(props).map(elem => <p>{elem}</p>)}</div>

// FC<{ greeting: string; }>
const Result = Curry(['hello', 'holla', 'hi']) 

// hello - is a required property
const jsx = <Result hello={0} holla={1} hi={2} />
Enter fullscreen mode Exit fullscreen mode

Thanks to Curry function, we can apply some constraints on our Result component. If you are curious how to infer ['hello', 'holla', 'hi'] tuple, you might be interested in my previous article.

ToRecord recursively iterates through each element in the tuple and accumulates each key/value in the Cache record.
Please don't focus too much on this utility type.

It looks like we can do more. What about component factory?

This example I found here

5

Given the type

type EnumerableComponentFactory = <C, I>(config: {
  Container: React.ComponentType<C&gt
  Item: React.ComponentType<I>;
}) => React.FC<{ items: I[] }>;

with the following implementation

const Enumerable: EnumerableComponentFactory =
  ({ Container, Item }) =>
  ({ items }) =>
    (
      <Container>
        {items.map((props, index) => (
          <Item key={index} {...props} />
        ))}
      </Container>
    );

and…


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

type EnumerableComponentFactory = <I>(config: {
    Container: FC<{ children: JSX.Element[] }>;
    Item: ComponentType<I>;
}) => FC<{ items: I[] }>;

const Enumerable: EnumerableComponentFactory =
    ({ Container, Item }) =>
        ({ items }) =>
        (
            <Container>
                {items.map((props, index) => (
                    <Item key={index} {...props} />
                ))}
            </Container>
        );

const UnorderedList = Enumerable({
    Container: ({ children }) => <ul>{children}</ul>,
    Item: ({ title }: { title: string }) => <li>{title}</li>,
});

const result = <UnorderedList items={[{ title: "Something" }]} />;
Enter fullscreen mode Exit fullscreen mode

It took me a bit to understand what's going on here.
So, I hope you understood the main idea. You have a function which returns a react functional components FC. First function receives some arguments. Props of returned FC depends on these arguments.

How about creating Accordeon component and writing some crazy an unreadable typings?

It should have a children with isOpen prop. Each child is also a React component that needs unique props from the parent that other children may not use. isOpen property is required for each component.
I know, it hards to understand my requirements :D.

Here you have expected behaviour:

import React, { FC } from "react";

type BaseProps = {
    isOpen: boolean;
};

const WithTitle: FC<BaseProps & { title: string }> =
    ({ isOpen, title }) => <p>{title}</p>;

const WithCount: FC<BaseProps & { count: number }> =
    ({ isOpen, count }) => <p>{count}</p>;

const Container = Curry([WithCount, WithTitle]);

/**
 * Ok
 */
const result = <Container title={"hello"} count={42} />; // ok

/**
 * Error
 */

// because [count] is string instead of number
const result_ = <Container title={"hello"} count={"42"} />;

// because second component does not expect [isOpen] property
const Container_ = Curry([WithCount, () => null]);

Enter fullscreen mode Exit fullscreen mode

WithCount and WithTitle expects {title: string} and {count: number} accordingly, hence Container should expect {title: string, count: number}.

Let's start with some utility types.

First of all, we need to be able to infer props from FC<Props>

type ExtractProps<F extends FC<any>> = F extends FC<infer Props>
    ? Props
    : never;
{
    type Test = ExtractProps<FC<{ age: number }>> // { age: number }
}
Enter fullscreen mode Exit fullscreen mode

Then, we need to check if every component has expected props.

type IsValid<
    Components extends Array<FC<BaseProps>>
    > =
    ExtractProps<[...Components][number]> extends BaseProps
    ? Components
    : never;
{
    type Test1 = IsValid<[FC<unknown>]> // never
    type Test2 = IsValid<[FC<BaseProps>]> //[React.FC<BaseProps>]
}
Enter fullscreen mode Exit fullscreen mode

Now, we need to extract all properties from all passed components, merge them and omit isOpen, because our Result should not accept it.

// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> =
    (U extends any ? (k: U) => void : never) extends (
        k: infer I
    ) => void
    ? I
    : never;

type GetRequired<T> = UnionToIntersection<
    // make sure we have a deal with array
    T extends Array<infer F>
    ? // make sure that element in the array extends FC
    F extends FC<infer Props>
    ? // if Props extends BaseProps
    Props extends BaseProps
    ? // Omit isOpen property, since it is not needed
    Omit<Props, "isOpen">
    : never
    : never
    : never
>
{
    type Test = keyof GetRequired<[
        FC<BaseProps & { title: string }>,
        FC<BaseProps & { count: number }>
    ]> // "title" | "count"
}
Enter fullscreen mode Exit fullscreen mode

We can put it all other.

import React, { FC } from "react";

type BaseProps = {
    isOpen: boolean;
};

const WithTitle: FC<BaseProps & { title: string }> =
    ({ isOpen, title }) => <p>{title}</p>
const WithCount: FC<BaseProps & { count: number }> =
    ({ isOpen, count }) => <p>{count}</p>

// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> =
    (U extends any ? (k: U) => void : never) extends (
        k: infer I
    ) => void
    ? I
    : never;

type GetRequired<T> = UnionToIntersection<
    // make sure we have a deal with array
    T extends Array<infer F>
    ? // make sure that element in the array extends FC
    F extends FC<infer Props>
    ? // if Props extends BaseProps
    Props extends BaseProps
    ? // Omit isOpen property, since it is not needed
    Omit<Props, "isOpen">
    : never
    : never
    : never
>
{
    type Test = keyof GetRequired<[
        FC<BaseProps & { title: string }>,
        FC<BaseProps & { count: number }>
    ]> // "title" | "count"
}

type ExtractProps<F extends FC<any>> = F extends FC<infer Props>
    ? Props
    : never;
{
    type Test = ExtractProps<FC<{ age: number }>> // { age: number }
}

type IsValid<
Components extends Array<FC<BaseProps>>
> =
    ExtractProps<[...Components][number]> extends BaseProps 
    ? Components 
    : never;
{
    // never
    type Test1 = IsValid<[FC<unknown>]> 
    // [React.FC<BaseProps>]
    type Test2 = IsValid<[FC<BaseProps>]> 
}

const Curry =
    <Comps extends FC<any>[], Valid extends IsValid<Comps>>(
        /**
         * If each Component expects BaseProps,
         * sections argument will evaluate to [...Comps] & [...Comps],
         * otherwise to [...Comps] & never === never
         */
        sections: [...Comps] & Valid
    ) =>
        (props: GetRequired<[...Comps]>) =>
        (
            <>
                {sections.map((Comp: FC<BaseProps>) => (
                    // isOpen is required
                    <Comp isOpen={true} {...props} />
                ))}
            </>
        );

const Container = Curry([WithCount, WithTitle]);

const result = <Container title={"hello"} count={42} />; // ok

const result_ = <Container title={"hello"} count={"42"} />; // expected error

const Container_ = Curry([WithCount, () => null]); // expected error
Enter fullscreen mode Exit fullscreen mode

P.S. If you have some interesting examples of composing React components, please let me know.

The end.

💖 💪 🙅 🚩
captainyossarian
yossarian

Posted on July 20, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related