24
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
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.
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.
24