Reputation: 593
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>
);
}
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
Reputation: 42170
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
The code you had before is like 90% there already, but could use a few improvements:
flair[$name]
that the union types did.Extract<IconNames, keyof Icons[F]>
is the same as just keyof Icons[F]
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>
.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>
);
}
Upvotes: 2