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:- Create a duplicate component which doesn't take a
lat
orlng
property and doesn't apply the styles - Make
lat
andlng
optional - 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: T3:}
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: T4:}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: T3:}
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: number5:height: number6:} & (T extends "flatscreen"7:? {8:wallMounted: boolean9:}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
andlng
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: number3:lng: number4:}5:6:type MapIconMode = "map" | "button"7:type MapIconProps<T extends MapIconMode> = {8:iconName: string9:mode: T10:} & (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-icons2: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 be10:{ lat: undefined, lng: undefined }, which is okay for our purposes11:12:One downside to this approach is that TypeScript will insist that `lat` and13:`lng` don't exist, so we have to cast props as any to get those values. One14:solution to this is to conditionally append Partial<Coordinates> or the regular15:Coordinates type depending on T, rather than completely omitting Coordinates16:all together.17:*/18:const { lat, lng } = props as any19:const position = { lat, lng }20:const IconComponent = iconLookup[props.iconName]21:return (22:<div23:{...position}24:style={25:props.mode === "map"26:? {27:color: "red",28:}29:: undefined30:}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:<MapIcon4:mode="map"5:iconName="business"6:lat={32.15126966740565}7:lng={-110.3480100497526}8:/>9:10:// The following will throw TypeScript errors!11:<MapIcon12:mode="button"13:iconName="business"14:lat={32.15126966740565} // ERR: Property "lat" is not assignable to type15:lng={-110.3480100497526} // ERR: Property "lng" is not assignable to type16:/>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.