lifeiscontent
lifeiscontent

Reputation: 593

How can I write a react component that has dependent props in TypeScript?

How can I write Typescript types for a React component where the value of one of the props depends on the value of another prop?

I am trying to write an Icon component that looks up the SVG path for an icon from a pre-defined map. The map is nested such that the top-level properties are the icon styles. Each of those styles has its own map where the properties are the icon names. Like this:

const icons = {
  regular: {
    check: '',
  },
  solid: {
    on: '',
  },
};

The component takes a prop $flair which is the icon style and $name which is the icon name. I need these two props to be related such that the values of $name are limited to the icons of this $flair. If $flair is 'regular' then $name can be 'check' but it cannot be 'on'.

Here is what I have to so far:

type Icons = typeof icons;
type FlairNames = keyof Icons;
type IconNames = keyof Icons['regular'] | keyof Icons['solid'];

type Test = Extract<IconNames, keyof Icons['regular']>;

interface IconProps<F extends FlairNames> {
  $size?: number;
  $flair: F;
  $name: Extract<IconNames, keyof Icons[F]>;
}

function IconImpl({
  $size,
  $flair,
  $name,
}: IconProps<'regular'> | IconProps<'solid'>): JSX.Element {
  const flair = icons[$flair];
  const d = flair[$name];

  return (
    <svg>
      <path d={d} />
    </svg>
  );
}

Typescript Playground Link

I'd like to be able to use the component as follows:

<Icon $flair="regular" $name="check" />
<Icon $flair="solid" $name="on" />
<Icon $flair="regular" $name="does-not-exist" /> // error
<Icon $flair="regular" /> // error
<Icon $name="on" /> // error

Upvotes: 0

Views: 1491

Answers (1)

Linda Paiste
Linda Paiste

Reputation: 42170

With a Union

When you have two props that need to be a matching pair it's actually better (in my opinion) to use a union of all valid pairs instead of a generic. When you have F extends FlairNames the value of F can be the union ('regular' | 'solid') so we can't actually guarantee a match.

You can compute those matching pairs by using a mapped type:

type PossiblePairings = {
  // for each flair type (regular/solid)
  [FlairName in keyof Icons]: {
    // expect the prop flair to be this flair name
    $flair: FlairName,
    // and the prop name to be one of the keys of this flair object
    $name: keyof Icons[FlairName];
  }
  // access by keyof icon to flatten the map into a union
}[keyof Icons]

This resolves to the union:

type PossiblePairings = {
    $flair: "regular";
    $name: "check";
} | {
    $flair: "solid";
    $name: "on";
}

So now we include this type in IconProps, which is no longer a generic.

type IconProps = {
  $size?: number;
} & PossiblePairings;

We do have to make one as assertion in the implementation. Typescript doesn't quite understand that our second-level key is always a property of the first key's object. So we get an error on accessing flair[$name]:

Element implicitly has an 'any' type because expression of type '"check" | "on"' can't be used to index type '{ check: string; } | { on: string; }'.

Property 'check' does not exist on type '{ check: string; } | { on: string; }'

function Icon({
  $size,
  $flair,
  $name,
}: IconProps): JSX.Element {
  const flair = icons[$flair];
  const d = (flair as Record<string, string>)[$name];

  return (
    <svg>
      <path d={d} />
    </svg>
  );
}

This works with all your test cases:

<Icon $flair="regular" $name="check" />
<Icon $flair="solid" $name="on" />

<Icon $flair="regular" $name="on" /> // error
<Icon $flair="solid" $name="check" /> // error

<Icon $flair="regular" $name="does-not-exist" /> // error
<Icon $flair="regular" /> // error
<Icon $name="on" /> // error

Typescript Playground Link

With Generics

The code you had before is like 90% there already, but could use a few improvements:

  • it has the same error on flair[$name] that the union types did.
  • Extract<IconNames, keyof Icons[F]> is the same as just keyof Icons[F]
  • We would prefer not to type out all of the flair types in IconProps<'regular'> | IconProps<'solid'>, so we should make the component be a generic which is dependent on the flair type and say that the props are IconProps<F>.
  • After making those changes, the error on flair[$name] goes away, but now there is an error further down on <path d={d} />

This revised version should work:

type FlairNames = keyof Icons;

interface IconProps<F extends FlairNames> {
  $size?: number;
  $flair: F;
  $name: keyof Icons[F];
}

function Icon<F extends FlairNames>({
  $size,
  $flair,
  $name,
}: IconProps<F>): JSX.Element {
  const flair = icons[$flair];
  const d = flair[$name] as unknown as string;

  return (
    <svg>
      <path d={d} />
    </svg>
  );
}

Typescript Playground Link

Upvotes: 2

Related Questions