20
How to type React props as a pro in TypeScript
This article consists of 4 parts which are unrelated to each other.
Everybody who works with TypeScript and React knows how to type a props, right?
Let's imagine, that we have three valid states A
, B
and C
.
enum Mode {
easy = 'easy',
medium = 'medium',
hard = 'hard'
}
type A = {
mode: Mode.easy;
data: string;
check: (a: A['data']) => string
}
type B = {
mode: Mode.medium;
data: number;
check: (a: B['data']) => number
}
type C = {
mode: Mode.hard;
data: number[];
check: (a: C['data']) => number
}
Now, we have to make sure that our component should accept only valid props:
type Props = A | B | C;
const Comp: FC<Props> = (props) => {
if (props.mode === Mode.easy) {
const x = props // A
}
if (props.mode === Mode.medium) {
const x = props // B
}
if (props.mode === Mode.hard) {
const x = props // C
}
return null
}
Nothing complicated right?
Now, try to call props.check
outside of condition statement.
const Comp: FC<Props> = (props) => {
props.check(props.data) // error
return null
}
But why error?
TL; DR;
Multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred.
In our case:
type Intersection = string & number & number[] // never
This is why check
expects never
type.
Almost forgot, please dont forget about destructure, it does not play well with TS type infering:
const Comp: FC<Props> = ({ check, data, mode }) => {
if (mode === Mode.easy) {
check(data) // error
}
return null
}
If you want to use destructure - please use also typeguards.
const isEasy = <M extends Mode>(
mode: M,
check: Fn
): check is Extract<Props, { mode: Mode.easy }>['check'] =>
mode === Mode.easy
Since we have added extra function to our codebase, we should test, right?
I'd like to show you the way without any extra checks.
I'm not claiming that it is safer or better option than using typeguards. In fact - it is not. You can use this approach if you don't want to provide any changes to business logic of your application. After this change, nobody will ask you to write unit tests :) Imagine situation where you just have to migrate from js
to ts
.
In order to allow calling check
we need to overload it.
Let's split our exercise into 5 smaller taksk:
1. Get key name where property is a function.
// Get keys where value is a function
type FnProps<T> = {
[Prop in keyof T]: T[Prop] extends Fn ? Prop : never
}[keyof T]
// check
type Result0 = FnProps<Props>
2. Get union of all functions.
type Values<T> = T[keyof T]
type FnUnion<PropsUnion> = Values<Pick<PropsUnion, FnProps<PropsUnion>>>
// | ((a: A['data']) => string)
// | ((a: B['data']) => number)
// | ((a: C['data']) => number)
type Result1 = FnUnion<Props>
3. Compute less specific overload
type ParametersUnion<PropsUnion> =
FnUnion<PropsUnion> extends Fn
? (a: Parameters<FnUnion<PropsUnion>>[0]) =>
ReturnType<FnUnion<PropsUnion>>
: never
// (a: string | number | number[]) => string | number
type Result2 = ParametersUnion<Props>
4. In order to convert function union to overloads, we need to use intersection instead of union. So, lets merge our function union with less specific overload
// 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 Overload<PropsUnion> =
& UnionToIntersection<PropsUnion[FnProps<PropsUnion>]>
& ParametersUnion<PropsUnion>
// & ((a: A['data']) => string)
// & ((a: B['data']) => number)
// & ((a: C['data']) => number)
// & ((a: string | number | number[]) => string | number)
type Result3 = Overload<Props>
5. And the last step. Wee need merge our union with overloaded function. In other words, we will just override our checkproperty
type OverloadedProps<PropsUnion> =
& PropsUnion
& Record<FnProps<PropsUnion>, Overload<PropsUnion>>
// Props & Record<"check", Overload<Props>>
type Result4 = OverloadedProps<Props>
Full example:
import React, { FC } from 'react'
enum Mode {
easy = 'easy',
medium = 'medium',
hard = 'hard'
}
type A = {
mode: Mode.easy;
data: string;
check: (a: A['data']) => string
}
type B = {
mode: Mode.medium;
data: number;
check: (a: B['data']) => number
}
type C = {
mode: Mode.hard;
data: number[];
check: (a: C['data']) => number
}
type Fn = (...args: any[]) => any
// 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 Props = A | B | C;
// Get keys where value is a function
type FnProps<T> = {
[Prop in keyof T]: T[Prop] extends Fn ? Prop : never
}[keyof T]
// check
type Result0 = FnProps<Props>
type Values<T> = T[keyof T]
type FnUnion<PropsUnion> = Values<Pick<PropsUnion, FnProps<PropsUnion>>>
// | ((a: A['data']) => string)
// | ((a: B['data']) => number)
// | ((a: C['data']) => number)
type Result1 = FnUnion<Props>
type ParametersUnion<PropsUnion> =
FnUnion<PropsUnion> extends Fn
? (a: Parameters<FnUnion<PropsUnion>>[0]) =>
ReturnType<FnUnion<PropsUnion>>
: never
// (a: string | number | number[]) => string | number
type Result2 = ParametersUnion<Props>
type Overload<PropsUnion> =
& UnionToIntersection<PropsUnion[FnProps<PropsUnion>]>
& ParametersUnion<PropsUnion>
// & ((a: A['data']) => string)
// & ((a: B['data']) => number)
// & ((a: C['data']) => number)
// & ((a: string | number | number[]) => string | number)
type Result3 = Overload<Props>
type OverloadedProps<PropsUnion> =
& PropsUnion
& Record<FnProps<PropsUnion>, Overload<PropsUnion>>
// Props & Record<"check", Overload<Props>>
type Result4 = OverloadedProps<Props>
const Comp: FC<OverloadedProps<Props>> = (props) => {
const { mode, data, check } = props;
if (props.mode === Mode.easy) {
props.data // string
}
const result = check(data) // string | number
return null
}
Please keep in mind, this is wrong way of typing your props. Treat it as a temporary solution.
Let's consider another example from stackoverflow
I have two components with similar props, but there is a crucial difference. One component, called TabsWithState
takes only a single prop tabs
, which is an array of objects of the following shape:
interface ItemWithState {
name: string
active: boolean;
}
interface WithStateProps {
tabs: ItemWithState[];
};
Another similar…
We have two components with similar props, tabs
property is common:
interface ItemWithState {
name: string;
active: boolean;
}
interface ItemWithRouter {
name: string;
path: string;
}
type WithStateProps = {
tabs: ItemWithState[];
};
type WithRouterProps = {
withRouter: true;
baseUrl?: string;
tabs: ItemWithRouter[];
};
const TabsWithRouter: FC<WithRouterProps> = (props) => null
const TabsWithState: FC<WithStateProps> = (props) => null
Also, we have higher order component:
type TabsProps = WithStateProps | WithRouterProps;
const Tabs = (props: TabsProps) => {
if (props.withRouter) { // error
return <TabsWithRouter {...props} />; // error
}
return <TabsWithState {...props} />; // error
};
We ended up with three errors.
TS will not allow you to get withRouter
property, since it is optional. Instead, it allows you to get only common property which is tabs
. This is expected behavior.
There is one fix/workaround. We can add withRouter?:never
to our WithStateProps
type.
Now it works and infers the type of {...props}
. But it has one small drawback: it allows us to pass to Tabs
component illegal props:
import React, { FC } from 'react'
interface ItemWithState {
name: string;
active: boolean;
}
interface ItemWithRouter {
name: string;
path: string;
}
type WithStateProps = {
withRouter?: never;
tabs: ItemWithState[];
};
type WithRouterProps = {
withRouter: true;
baseUrl?: string;
tabs: ItemWithRouter[];
};
const TabsWithRouter: FC<WithRouterProps> = (props) => null
const TabsWithState: FC<WithStateProps> = (props) => null
type TabsProps = WithStateProps | WithRouterProps;
const Tabs = (props: TabsProps) => {
if (props.withRouter) {
return <TabsWithRouter {...props} />;
}
return <TabsWithState {...props} />;
};
const Test = () => {
return (
<div>
<Tabs // With incorrect state props
baseUrl="something"
tabs={[{ name: "myname", active: true }]}
/>
</div>
);
};
This approach is bad. Let's try another one with typeguard:
interface ItemWithState {
name: string;
active: boolean;
}
interface ItemWithRouter {
name: string;
path: string;
}
type WithStateProps = {
tabs: ItemWithState[];
};
type WithRouterProps = {
withRouter: true;
baseUrl?: string;
tabs: ItemWithRouter[];
};
const TabsWithRouter: FC<WithRouterProps> = (props) => null
const TabsWithState: FC<WithStateProps> = (props) => null
type TabsProps = WithStateProps | WithRouterProps;
const hasProperty = <Obj, Prop extends string>(obj: Obj, prop: Prop)
: obj is Obj & Record<Prop, unknown> =>
Object.prototype.hasOwnProperty.call(obj, prop);
const Tabs = (props: TabsProps) => {
if (hasProperty(props, 'withRouter')) {
return <TabsWithRouter {...props} />;
}
return <TabsWithState {...props} />;
};
const Test = () => {
return (
<div>
<Tabs // With incorrect state props
baseUrl="something"
tabs={[{ name: "myname", active: true }]}
/>
</div>
);
};
I believe this approach is much better, because we don't need to use any hacks
. Our WithStateProps
type should not have any extra optional props. But it still has the same drawback. Illegal state is allowed.
Seems we forgot about function overloading. It works the same way with react components since they are just simple functions.
Please keep in mind, that intersection of functions produces overloads:
// type Overload = FC<WithStateProps> & FC<WithRouterProps>
const Tabs: FC<WithStateProps> & FC<WithRouterProps> = (props: TabsProps) => {
if (hasProperty(props, 'withRouter')) {
return <TabsWithRouter {...props} />;
}
return <TabsWithState {...props} />;
};
const Test = () => {
return (
<div>
<Tabs // With correct state props
tabs={[{ name: "myname", active: true }]}
/>
<Tabs // With incorrect state props
baseUrl="something"
tabs={[{ name: "myname", active: true }]}
/>
<Tabs // WIth correct router props
withRouter
tabs={[{ name: "myname", path: "somepath" }]}
/>
<Tabs // WIth correct router props
withRouter
baseUrl="someurl"
tabs={[{ name: "myname", path: "somepath" }]}
/>
<Tabs // WIth incorrect router props
withRouter
tabs={[{ name: "myname", active: true }]}
/>
</div>
);
};
Question What if we have 5 elements in union?
Answer We can use conditional distributive types:
import React, { FC } from 'react'
interface ItemWithState {
name: string;
active: boolean;
}
interface ItemWithRouter {
name: string;
path: string;
}
type WithStateProps = {
tabs: ItemWithState[];
};
type WithRouterProps = {
withRouter: true;
baseUrl?: string;
tabs: ItemWithRouter[];
};
const TabsWithRouter: FC<WithRouterProps> = (props) => null
const TabsWithState: FC<WithStateProps> = (props) => null
type TabsProps = WithStateProps | WithRouterProps;
const hasProperty = <Obj, Prop extends string>(obj: Obj, prop: Prop)
: obj is Obj & Record<Prop, unknown> =>
Object.prototype.hasOwnProperty.call(obj, prop);
// 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 Distributive<T> = T extends any ? FC<T> : never
type Overload = UnionToIntersection<Distributive<TabsProps>>
const Tabs: Overload = (props: TabsProps) => {
if (hasProperty(props, 'withRouter')) {
return <TabsWithRouter {...props} />;
}
return <TabsWithState {...props} />;
};
const Test = () => {
return (
<div>
<Tabs // With correct state props
tabs={[{ name: "myname", active: true }]}
/>
<Tabs // With incorrect state props
baseUrl="something"
tabs={[{ name: "myname", active: true }]}
/>
<Tabs // WIth correct router props
withRouter
tabs={[{ name: "myname", path: "somepath" }]}
/>
<Tabs // WIth correct router props
withRouter
baseUrl="someurl"
tabs={[{ name: "myname", path: "somepath" }]}
/>
<Tabs // WIth incorrect router props
withRouter
tabs={[{ name: "myname", active: true }]}
/>
</div>
);
};
You can also use this approach:
type Overloading =
& ((props: WithStateProps) => JSX.Element)
& ((props: WithRouterProps) => JSX.Element)
This is the matter of style.
I hope you did not tired yet.
Let's say we have Animal
component with next constraints:
- If
dogName
is empty string or unset,canBark
should be false - If
dogName
is not empty string,canBark
should be true
type NonEmptyString<T extends string> = T extends '' ? never : T;
type WithName = {
dogName: string,
canBark: true,
}
type WithoutName = {
dogName?: '',
canBark: false
};
type Props = WithName | WithoutName;
Since React component is just a regular function, we can overload it and even use some generic arguments:
type Overloadings =
& ((arg: { canBark: false }) => JSX.Element)
& ((arg: { dogName: '', canBark: false }) => JSX.Element)
& (<S extends string>(arg: { dogName: NonEmptyString<S>, canBark: true }) => JSX.Element)
const Animal: Overloadings = (props: Props) => {
return null as any
}
Let's test it:
import React, { FC } from 'react'
type NonEmptyString<T extends string> = T extends '' ? never : T;
type WithName = {
dogName: string,
canBark: true,
}
type WithoutName = {
dogName?: '',
canBark: false
};
type Props = WithName | WithoutName;
type Overloadings =
& ((arg: { canBark: false }) => JSX.Element)
& ((arg: { dogName: '', canBark: false }) => JSX.Element)
& (<S extends string>(arg: { dogName: NonEmptyString<S>, canBark: true }) => JSX.Element)
const Animal: Overloadings = (props: Props) => {
return null as any
}
const Test = () => {
return (
<>
<Animal dogName='' canBark={false} /> // ok
<Animal dogName='a' canBark={true} /> // ok
<Animal canBark={false} /> // ok
<Animal dogName='a' canBark={false} /> // error
<Animal dogName='' canBark={true} /> // error
<Animal canBark={true} /> // error
</>
)
}
Let's say we have a component which expects foo
and bar
properties to be strings, but property foo
can't be hello
.
In order to do it, we should use explicit generic for foo
and bar
property.
This is easy:
import React from 'react'
type Props<F extends string = '', B extends string = ''> = {
foo: F;
bar: B;
}
type ConditionalProps<T> = T extends { foo: infer Foo; bar: string } ? Foo extends 'hello' ? never : T : never
const Example = <F extends string, B extends string>(props: ConditionalProps<Props<F, B>>) => {
return null as any
}
const Test = () => {
<>
<Example foo='hello' bar='bye' /> // expected error
<Example foo='not hello' bar='1' /> // ok
</>
}
Thanks for the reading.
20