How to type React props as a pro in TypeScript
Tags: react, typescript, javascript, tutorial
Published: 2021-07-11 13:38:31

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?

#First part

Let's imagine, that we have three valid states `A`, `B` and `C`.
```typescript
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:
```typescript
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.
```typescript
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:
```typescript
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:

```typescript
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.
```typescript
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.**
```typescript
// 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.**
```typescript
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**
```typescript
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**
```typescript

// 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**
```typescript
type OverloadedProps<PropsUnion> =
  & PropsUnion
  & Record<FnProps<PropsUnion>, Overload<PropsUnion>>


// Props & Record<"check", Overload<Props>>
type Result4 = OverloadedProps<Props>
```

Full example:
```typescript
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.

# Second Part

Let's consider another example from stackoverflow {% stackoverflow 68308390 %}
We have two components with similar props, `tabs` property is common:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript

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:
```typescript

// 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:
```typescript
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:
```typescript

type Overloading =
  & ((props: WithStateProps) => JSX.Element)
  & ((props: WithRouterProps) => JSX.Element)

```
This is the matter of style.

I hope you did not tired yet.

#Third Part

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

```typescript

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:
```typescript

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:
```typescript
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
        </>
    )
}
```

#Fourth part

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:
```typescript
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.