Currying React components in TypeScript
yossarian
Posted on July 20, 2021
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
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} />
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.
Typescript: Type Inference on function arguments
yossarian ・ Jul 18 '21
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
Given the type
type EnumerableComponentFactory = <C, I>(config: {
Container: React.ComponentType<C>
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" }]} />;
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]);
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 }
}
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>]
}
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"
}
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
P.S. If you have some interesting examples of composing React components, please let me know.
The end.
Posted on July 20, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.