ezb.sh logo
Back to Home

Creating Dynamic Prop Types With Generics in TypeScript

Add or remove constraints from your props with generics
Written by ezb
react
typescript
For a project I'm working on at university, I am using the google-map-react npm package to render icons on a map. Such icons were abstracted into a MapIcon component, which takes in an iconName prop to switch between different icons. For it to be rendered properly on the map, I also need to pass a lat and lng property so the map can place it over a real-world location.
A new requirement was added to the project where each item on the map ust have a corresponding button in the site's sidebar, so the user could either interact with the map or a more simplified list. I found that including this MapIcon component as a section of the button made it easier for users to tell which item in the sidebar correlated to those in the map. However, I don't need a lat or a lng to render an icon in the button. There are some additional styles I also don't want to apply if the MapIcon is going to be used outside of the map. As such, I was left with a few choices:
  1. Create a duplicate component which doesn't take a lat or lng property and doesn't apply the styles
  2. Make lat and lng optional
  3. Use generics!
I didn't like option #1, since creating duplicate components that do almost the same thing is something I don't like doing unless I really need to. I didn't like option #2 either, since making lat and lng optional doesn't capture the requirement that the google-map-react package has. So, I turned to option 3!
If you're already keen on TypeScript generics, you can just skip to how to implement the dynamic props.

Creating a generic type

We can create a generic props type by using the following syntax:
1:
type MyGenericType<T> = {
2:
myItem: T
3:
}
This piece of code says that we can create a version of MyGenericType where the myItem type takes a type of our choosing (represented by T).
1:
// item1 and item2 will pass the TypeScript compiler...
2:
const item1: MyGenericType<string> = {
3:
myItem: "Hello!",
4:
}
5:
const item2: MyGenericType<number> = {
6:
myItem: 10,
7:
}
8:
9:
// But this item won't!
10:
const item3: MyGenericType<object> = {
11:
myItem: 10, // ERR: Type 'number' is not assignable to type 'object'
12:
}

Restricting T

In the example above, T can take any TypeScript type. This is great and all, but generics become very powerful once they are constrained to a certain subset of types. What I mean by this is that we can create a generic type which is only allowed to be, say, string or number. This is done using the extends keyword:
1:
// Now, T can only be either `string` or `number`
2:
type MyGenericType<T extends string | number> = {
3:
myItem: T
4:
}
5:
6:
// The previous two examples are still a-okay:
7:
const item1: MyGenericType<string> = {
8:
myItem: "Hello!",
9:
}
10:
const item2: MyGenericType<number> = {
11:
myItem: 10,
12:
}
13:
14:
// But now we cannot pass `object` as T:
15:
// ERR: Type 'object' does not satisfy the constraint 'string | number'
16:
const item3: MyGenericType<object> = {
17:
myItem: {},
18:
}
We can even extend string literal types, which is going to end up becoming the basis for our dynamic props type later on:
1:
type MyGenericType<T extends "red" | "green" | "blue"> = {
2:
color: T
3:
}

Conditional Types

The concept of conditional types blew me away when I first used them. It almost felt like breaking the rules! Types are static after all, so how can their value be conditionally determined?
Syntactically, conditional types are achieved with a ternary operator. This fact alone is what cleared up the confusion for me. Let's ask the same question I just did, but instead of static types, think of const variables:
The concept of conditional constants blew me away when I first used them. It almost felt like breaking the rules! These are constants after all, so how can their value be conditionally determined?
Sticking with constants for a second to drive the point home, it's really clear that the following statement doesn't defy logic:
1:
const someNumber: number = getRandomIntegerBetween(0, 100)
2:
const isOverFifty: string = someNumber > 50 ? "yes" : "no"
Upon assignment, we evaluate some condition, and then set the value accordingly. This is exactly what happens with conditional types! See if you can pick out what's going on in the following example:
1:
type TelevisionType = "flatscreen" | "crt"
2:
3:
type Television<T extends TelevisionType> = {
4:
width: number
5:
height: number
6:
} & (T extends "flatscreen"
7:
? {
8:
wallMounted: boolean
9:
}
10:
: unknown)
The "base" type for Television in this case is { width: number, height: number }. Then, we see an &, which denotes a type union. The type being unioned with the base type is either unknown, or { wallMounted: boolean } - this decision is contingent on T "extending" (equalling) the string flatscreen. The result is a type which conditionally has a wallMounted variable.
You can see this in action in this playground I created. Try creating a flatscreen TV without a wallMounted field and see what happens.

Putting it all together

Now that we know how to modify our type's body based on a generic, we are armed with all the knowledge we need to accomplish that MapIcon functionality I described at the beginning of this article.
First off, let's create the types. A quick reminder of the requirements:
  • lat and lng should only be present if the icon is in the map
  • Some styles should be conditionally disabled if the icon is not in the map
We can accomplish this by creating a mode property that takes the type of the generic:
1:
type Coordinates = {
2:
lat: number
3:
lng: number
4:
}
5:
6:
type MapIconMode = "map" | "button"
7:
type MapIconProps<T extends MapIconMode> = {
8:
iconName: string
9:
mode: T
10:
} & (T extends "map" ? Coordinates : unknown)
Much like how wallMounted was only available to flatscreens in the TV example, MapIconProps will only require a latitude and longitude if mode is set to "map". Now, we can create the implementation of the component!
1:
// Icons are imported from react-icons
2:
3:
export const MapIcon = <T extends MapIconMode>(props: MapIconProps<T>) => {
4:
const iconLookup: Record<AllowedIconName, Function> = {
5:
school: IoMdSchool,
6:
business: MdOutlineBusiness,
7:
restaurant: IoMdRestaurant,
8:
}
9:
/* If `mode` is set to "button", this `position` variable will be
10:
{ lat: undefined, lng: undefined }, which is okay for our purposes
11:
12:
One downside to this approach is that TypeScript will insist that `lat` and
13:
`lng` don't exist, so we have to cast props as any to get those values. One
14:
solution to this is to conditionally append Partial<Coordinates> or the regular
15:
Coordinates type depending on T, rather than completely omitting Coordinates
16:
all together.
17:
*/
18:
const { lat, lng } = props as any
19:
const position = { lat, lng }
20:
const IconComponent = iconLookup[props.iconName]
21:
return (
22:
<div
23:
{...position}
24:
style={
25:
props.mode === "map"
26:
? {
27:
color: "red",
28:
}
29:
: undefined
30:
}
31:
>
32:
<IconComponent />
33:
</div>
34:
)
35:
}
Before we get into example usages, it should be noted that you needn't call your component with the typical generic syntax (i.e., <MapIcon<"map"> ... />). By setting the mode prop, TypeScript knows that T extends whatever you set the mode prop to. As such, here's some example usages of the MapIcon component:
1:
// The following are okay:
2:
<MapIcon mode="button" iconName="school" />
3:
<MapIcon
4:
mode="map"
5:
iconName="business"
6:
lat={32.15126966740565}
7:
lng={-110.3480100497526}
8:
/>
9:
10:
// The following will throw TypeScript errors!
11:
<MapIcon
12:
mode="button"
13:
iconName="business"
14:
lat={32.15126966740565} // ERR: Property "lat" is not assignable to type
15:
lng={-110.3480100497526} // ERR: Property "lng" is not assignable to type
16:
/>
17:
<MapIcon mode="map" iconName="school" /> // ERR: Missing properties "lat" and "lng"

Conclusion

Now you're ready to create dynamic properties on your components! It should be noted that changing what is allowed based on a property may lead to confusion. A lot of times where I thought about using this, a refactor ended up being the right solution instead. However, this is a pattern that I know I'll use for small components like my MapIcon in the future.
By the way, you can view an interactive version of the MapIcon example in this CodeSandbox.