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.