Learn the Key Concepts of TypeScript’s Powerful Generic and Mapped Types

Matías Hernández
author
Matías Hernández
synthwave toolbox

As you write more complex types, you'll notice your code growing.

You might also start seeing code duplication…

When you need to reuse TypeScript code, what do you do? How can you keep your code DRY? How do you reduce the boilerplate code?

There are a few main concepts in Typescript that help you accomplish that goal of reducing boilerplate and reusing more code.

We’ll be specifically be covering Generics and Mapped Types

Both concepts can be a bit scary at first, mostly because of a knowledge gap that creates a wall and makes the code un-readable at first.

Generics are a way to create, in the type world, a similar functionality that functions offer.

Mapped Types are a way to derive and reshape types into new ones.

But, before diving into these new concepts, you need to build up your knowledge with the key features that enable the power of Generics and Mapped Types.

The keyof operator

keyof is Typescript's answer to JavaScript’s Object.keys operator.

Object.keys returns a list of an object’s keys.

keyof does something similar but in the typed world only. It will return a literal union type listing the "properties" of an object-like type.

This operator is the base building block for advanced typing like mapped and conditional types.

The keyof operator takes an object type and produces a string or numeric literal union of its keys. - Typescript Handbook

type Colors = {
primary: '#eee',
primaryBorder: '#444',
secondary: '#007bff',
black: '#000',
white: '#fff',
whiteBorder: '#f2f2f7',
green: '#53C497',
darkGreen: '#43A17C',
infoGreen: '#23AEB7',
pastelLightGreen: '#F3FEFF',
}
type ColorKeys = keyof Colors; // "primary" | "primaryBorder" | "secondary" ....
function SomeComponent({ color }: { color: ColorKeys }) {
return "Something"
}
SomeComponent({ color: "WhateverColor"})
SomeComponent({ color: "primary"})

The code sample above is from a real webapp. The Colors type describes a set of colors that can be used across the application.

The keyof operator retrieves a literal union of all the possible colors from the Colors type.

Literal union means a Union type made up by literal values like "primary" | "primaryBorder"

The union is then used to type the props of SomeComponent, allowing the color argument to be one of the colors defined in the type.

keyof as constraint

The keyof operator can also be used to apply constraints to a generic.

For this example, it is enough to know that a Generic behaves similarly to a function argument and that the Generic type can have a type or constraint.

function getObjectProps<
Obj,
Key extends keyof Obj>(obj: Obj, key: Key): Obj[Key] {
return obj[key]
}

The “Generic” code is the odd angle brackets section <Obj, Key extends keyof Obj>. That section defines that this function will receive two "type parameters" that obey certain rules.

Then, the function declares that the arguments are of the type of that "type parameters" and it will return a type derived from the generic values as Obj[Key]

Let's dissect the generic portion of the above function definition:

  • Obj is the name used to identify this Generic parameter. Usually people use single letters to identify the generic, but IMHO it is more clear to use a better name like you would with a variable. The intention here is to accept any object.
  • The second generic parameter is Key extends keyof Obj. Here the extends keyword is used as a constraint and can be read as "Key is of ...." meaning that the Key generic can only be a value found in keyof Obj.
  • keyof Obj is, as mentioned before, a union of string literals from the properties of Obj and Obj is the first generic parameter. So in Typescript you can reference the previous generic directly.

So, does all of that mean?

It means that the function arguments of getObjectProps will accept any object inside the obj argument, but they key argument can only be a string literal that exists as a property of obj

const User = {
name: 'Matias',
site: 'matiashernandez.dev',
location: {
country: 'Chile',
city: 'Talca'
}
}
const email = getObjectProps(User, 'email') //Argument of type '"email"' is not assignable to parameter of type '"name" | "site" | "location"'.
const loc = getObjectProps(User,'location')

Other type of constraint that you can write using the keyof operator is to restrict the return type of a function.

function objectKeys<Obj extends Record<string, unknown>>(obj: Obj): (keyof Obj)[] {
return Object.keys(obj) as (keyof Obj)[];
}

The above example can be read as objectKeys accepts a obj arguments that has to be of type Record<string, unknown> and will return an array of all of the properties of that obj.

keyof and template literals.

You can also use keyof to construct complex template literals like the following example.

type State = {
modalOpen: boolean;
confirmationDone: boolean;
}
type SetActions = `set${keyof State}` // setModalOpen | setConfirmationDone

This will generate a union of literal strings based on the State properties concatenating the property name with the set word.

As you can see, the keyof operator can be small but it is an important piece that unlocks powerful operations when used in the right places.

The extends keyword

This keyword is very confusing at first glance. It's used in a few different places with different meanings.

The first usage of extends is for interface inheritance. This lets you create new interfaces that inherit the behavior of a previous one. In other words, it extends the base interface/class.

Interfaces have two main purposes:

  • Create the contract that a class must implement
  • Perform type declaration.
interface User {
firstName: string;
lastName: string;
email: string;
}
interface StaffUser extends User {
roles: Array<Role>
}

The first interface, User, describes a type with a few properties representing a general user of a certain application.

The second interface, named StaffUser, represents a user who is part of the organization and therefore has a set of roles. But this user also has firstName, lastName, and email.

You don't want to write that over and over again, right?

But more important, what if the general User entity changes? How can you be sure that the change is represented everywhere else?

That's why the StaffUser is written with the extends keyword, saying that this interface has all of the properties of the User interface, plus the ones defined by itself.

This usage of extends can be used to inherit from multiple interfaces at the same time by using a comma-separated list of the base interfaces.

interface StaffUser extends User, RoleUser {
// ...
}

This behavior can also be used to extends a Class

Another usage of the extends keyword is to narrow the scope of a generic to make it more useful.

export type QueryFunction<
T = unknown,
TQueryKey extends QueryKey = QueryKey,
> = (context: QueryFunctionContext<TQueryKey>) => T | Promise<T>

This usage of extends narrowing down or constraining the type of a generic is the corner stone to be able to implement conditional types since the extends will be used as condition to check if a generic is or not of a certain type.

In the example above, from a real world implementation of tanstack-query, a new type is defined: QueryFunction. This type accepts two generic values, T and TQueryKey.

The extends keyword here constrains the possible values of the TQueryKey generic to be of the type QueryKey, defined elsewhere in the source code. In other words, TQueryKey has to be of type QueryKey.

If you're not yet comfortable with the use of generics, think of them as function arguments in the type world. The QueryFunction type can be thought of as a function type that accepts two arguments (generics) named T, with a default value of unknown, and TQueryKey, with a default value of QueryKey.

This usage of extend, narrowing down or constraining the type of a generic, is the cornerstone of being able to implement conditional types, since extends is used as a condition to check whether a generic is or is not of a certain type.

The never keyword.

never is a type “value” that represents something that will never occur. This is very handy for implementing different types as conditionals and discriminated unions.

A simple example can be defining the type of a set of function arguments, where some of those arguments are dependent:

type CardWithDescription = {
title?: never;
footer?: never;
description: string;
}
type CardWithoutDescription = {
title: string;
footer: string;
description?: never;
}
type CardProps = CardWithDescription | CardWithoutDescription
function Card(props: CardProps) {
return null
}
import React from 'react';
function App() {
return (
<>
<Card title="Title" footer='footer' />
<Card description='description' />
<Card description='description' title="title" footer="footer" /> // ERROR
</>
)
}

A React component that can accept some props is a good example. In the above code, you can see that there are three types defined:

  • CardWithDescription defines a type that can have a description property as string, but with title and footer as never, meaning that they cannot be defined or used.
  • CardWithoutDescription is the opposite type, where description cannot be used, but title and footer are mandatory.
  • CardProps defines a union of the previous two which is used to type the props of the Card component.

With this setup, the Card component can only be used with description and no footer and no title, or vice versa. If for some reason you try to use the three props together, you'll get the following error:

Type '{ description: string; title: string; footer: string; }' is not assignable to type 'IntrinsicAttributes & CardProps'. Type '{ description: string; title: string; footer: string; }' is not assignable to type 'CardWithoutDescription'. Types of property 'description' are incompatible. Type 'string' is not assignable to type 'undefined'.

Generics

If you're serious about becoming a true expert and taking your career to the next level, check out Matt Pocock's Total TypeScript and learn the underlying principles and patterns of being an effective TypeScript engineer.

In any programming language you have ways to implement the DRY principle, TypeScript is no different.

Generics help you build well-defined and consistent APIs that are also reusable. You can use Generics to build dynamic and reusable pieces of code that resemble JavaScript functions.

Let's see an example of a generic in the wild.

type IsArray<T> = T extends any[] ? true : false;
type res1 = IsArray<number[]>;
type res2 = IsArray<["a", "b", "c"]>;
type res3 = IsArray<"this is not an array">

The example shows a type called isArray. It receives a Generic "parameter" called T and uses that to do some conditional "calculation" to return true or false.

This example can give you a hint: Generics are kind of like the function parameters of the typed world.

For convention, the name of the Generics is a single capital letter, but that is not required. You can pass any name to it.

type IsArray<Array> = Array extends any[] ? true : false;

To use generics, you need to follow the angle bracket syntax.

When Typescript sees angle brackets, it understands that there are type variables defined inside of them. You can pass as many generics as you need.

Then, you can use these "type parameters" inside the type definition to the right side of it.

Generics can be used all over the place in Typescript. For example, you can use them to create an interface that changes based on the generics used:

interface UserInfo<X,Y> {
name: X;
rol: Y;
}
const user: UserInfo<string, number> = {
name: "User 1",
rol: 1
}
const user2: UserInfo<string, string> = {
name: "User 2",
rol: "admin"
}

The interface UserInfo has two properties that depend on the generics used:

· name with a type of X · rol with a type of Y

So when you use UserInfo<string, string>, both properties will be strings.

You'll find generics in almost every TypeScript code-base. They're the basic way to create reusable chunks of code and also the basic tool that library authors use to create a flexible API.

Conclusion

Mapped types and generics are powerful tools for any TypeScript developer.

With a good understanding of the key features of typescript, such as the keyof operator, the extends keyword, the never keyword, and generics, you can unlock the full potential of TypeScript and write complex solutions for complex data shapes.

With these features, you can create and reuse code, narrow down types, and create custom types, all while ensuring that your code remains DRY.

For more curated TypeScript content, check out our TypeScript landing page!