Typescript generics inheritance
November 4th, 2022 - 10 minutes read
Generics is a TypeScript concept when we want to create a function more flexible without losing type safety. In short - if we have a function that accepts a string
, we can substitute it with a generic Value
and TypeScript will automatically inherit it for us.
When working with React, one of the most useful generics usage is when we need to define dependency/relationship between 2 or more properties. This blog post will focus exactly on this.
To demonstrate this behavior we need to have a component with few properties which has the same subset of values. A perfect example could be a Tabs component.
Starting point
The blog post's main focus is on types, so I want to have code as simple as possible. Example code will have Tabs
component which accepts items
prop and later map over it. activeKey
is used to mark which of the items
is active/selected. When a user clicks tab element, it passes tab key back to Parent
component using onChange
callback.
1type TabsProps = {
2 items: string[];
3 activeKey: string;
4 onChange: (key: string) => void;
5};
6
7const Tabs = ({ items, activeKey, onChange }: TabsProps) => {
8 return (
9 <ul>
10 {items.map((item) => (
11 <li
12 key={item}
13 onClick={() => onChange(item)}
14 style={{ backgroundColor: activeKey === item ? "orange" : "blue" }}
15 >
16 {item}
17 </li>
18 ))}
19 </ul>
20 );
21};
22
23const Parent = () => {
24 return (
25 <Tabs
26 items={["tab1", "tab2", "tab3"]}
27 activeKey="tab1"
28 onChange={(newValue) => {
29 // 🚨 newValue - string
30 }}
31 />
32 );
33};
34
With the current implementation, onChange
callback returns newValue
argument that is too broad (string
). We know that it can only be one of 'tab1' | 'tab2' | 'tab3'
values. So how can we limit it?
Generics
Note: When working with generics, it is kind of popular to use one letter generic variables like T
. It is kind of a legacy from other languages like "Java". But I prefer to use full names as this allows me to better express the meaning of the variable.
We do not want to pass any additional generics from the Parent
component. Everything has to be inferred. Changes should only happen in Tabs component. By using diamond syntax we introduce a new generic variable Key
. This informs TypeScript that Tabs
component uses generic props. So let's change all the places from string
to Key
.
1type TabsProps<Key> = {
2 items: Key[];
3 activeKey: Key;
4 onChange: (key: Key) => void;
5};
6
7const Tabs = <Key extends string>({
8 items,
9 activeKey,
10 onChange,
11}: TabsProps<Key>) => {
12 return (
13 ...
14 );
15};
16
17const Parent = () => {
18 return (
19 <Tabs
20 items={["tab1", "tab2", "tab3"]}
21 activeKey="tab1"
22 onChange={(newValue) => {
23 // ✅ newValue - "tab1" | "tab2" | "tab3"
24 }}
25 />
26 );
27};
28
Success! newValue
in onChange
callback now returns a correct type.
This is usually the place where most of tutorials end, but it has a flaw.
Expanded Key
Everything seems to work nicely, but we notice strange behavior. TypeScript allows to pass any string to activeKey
prop 😱.
1const Parent = () => {
2 return (
3 <Tabs
4 items={["tab1", "tab2", "tab3"]}
5 activeKey="tab4"
6 onChange={(newValue) => {
7 // 🚨 newValue - "tab1" | "tab2" | "tab3" | "tab4"
8 }}
9 />
10 );
11};
12
This is where the main confusion comes from. People often assume that generic Key
means that it will take the widest range ('tab1' | 'tab2' | 'tab3'
) and will not allow to have conflicting values. But this is not true!
We have specified only one limitation - Key extends string
. It means that string
is the widest available range.
The issue is that TypeScript does not know which variable - items
or activeKey
has a priority. We also have onChange
callback with Key
, but function arguments have lower priority.
TypeScript tries to infer Key
by combining all sources. And it continues to expand Key
as long as it fits into string
range.
In our case, 'tab1' | 'tab2' | 'tab3'
comes from items
, and 'tab4'
comes from activeKey
. Key
results in 'tab1' | 'tab2' | 'tab3' | 'tab4'
(As we can see in a previous error message).
Solution - Second generic
We need to change the relationship between our properties. We want to restrict activeKey
, so that valid values range would be only the ones defined in items
property.
TypeScript allows us to declare new generic parameter, which is restricted by other generic value using the same extends
syntax.
1import { useState } from "react";
2
3type TabsProps<Key, ActiveKey> = {
4 items: Key[];
5 activeKey: ActiveKey;
6 onChange: (key: Key) => void;
7};
8
9const Tabs = <Key extends string, ActiveKey extends Key>({
10 items,
11 activeKey,
12 onChange,
13}: TabsProps<Key, ActiveKey>) => {
14
Uses native constructs (future-proof).
Verbose, because need to pass additional generic to all type declarations.
Solution 2 - NoInfer
Introducing second (or sometimes even more) generics makes this very verbose. Also, it is quite hard to understand why a second generic was introduced until you have familiarised yourself with the problem. It would be nice to just tell TypeScript, that this property should not participate in generic type evaluation.
Seems community has found a way:
1type NoInfer<Type> = [Type][Type extends any ? 0 : never];
2
3type TabsProps<Key> = {
4 items: Key[];
5 activeKey: NoInfer<Key>;
6 onChange: (key: Key) => void;
7};
This works by taking advantage of the compiler's deferral of evaluating distributive conditional types when the checked type is an unresolved generic. It cannot "see" that NoInfer will evaluate to T, until T is some specific resolved type, such as after T has been inferred.
Identical utility type is also present in many TypeScript libraries like ts-toolbelt.
This works, but I have mixed feelings about this:
It is a short, but also very explicit way of saying to exclude property.
Have not found a place where they would explicitly mention this in TypeScript documentation. This is a bit troublesome because if TypeScript compiler behavior will change in the future, this approach would no longer work.
Conclusion
1const Parent = () => {
2 return (
3 <Tabs
4 items={["tab1", "tab2", "tab3"]}
5 // 🚨 TS2322: Type '"tab4"' is not assignable to type '"tab1" | "tab2" | "tab3"'
6 activeKey="tab4"
7 onChange={(newValue) => {
8 // ✅ newValue - "tab1" | "tab2" | "tab3"
9 }}
10 />
11 );
12};
13
Both of these solutions do the job - types are not expanded and error is shown at the correct place.
Important note, that this concept can be applied to many other type of components - Inputs, Selects, and many others.
In the end - correct type inheritance allows us to avoid bugs and write code without messy hacks.