DEV Community

loading...
Cover image for How to type React props as a pro in TypeScript

How to type React props as a pro in TypeScript

yossarian
Originally published at catchts.com Updated on ・7 min read

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.

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

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
};

Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

You can also use this approach:


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

Enter fullscreen mode Exit fullscreen mode

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

type NonEmptyString<T extends string> = T extends '' ? never : T;

type WithName = {
    dogName: string,
    canBark: true,
}

type WithoutName = {
    dogName?: '',
    canBark: false
};

type Props = WithName | WithoutName;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
        </>
    )
}
Enter fullscreen mode Exit fullscreen mode

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:

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
    </>

}
Enter fullscreen mode Exit fullscreen mode

Thanks for the reading.

Discussion (0)