An Introduction to TypeScript Generics

Matías Hernández
author
Matías Hernández
An Introduction to TypeScript Generics

Typescript Generics

It’s essential for any programmer to write reusable code.

Every programming language needs to be able to encapsulate functionality to be shared across the codebase. Typescript is no different in that, but it implements this feature in a slightly different way than your usual runtime language.

TypeScript uses Generics.

Generics are an essential tool for writing reusable and dynamic code in Typescript.

They allow you to create components that can be reused across the codebase and work with a variety of types. By leveraging generics, you can apply the DRY principle and create reusable, dynamic code.

In languages like C# and Java, one of the main tools in the toolbox for creating reusable components is generics, that is, being able to create a component that can work over a variety of types rather than a single one. This allows users to consume these components and use their own types. - Typescript Docs

In this article, we'll take a closer look at generics, exploring how they work and how to incorporate them into your Typescript code.

What are Generics?

Generics are a pattern that Typescript uses to allow developers to create reusable and dynamic code. It is represented using angle brackets (<>). This is used to declare a type argument that is used inside the function signature.

For example, the following code shows a simple function that uses generics to define the types of its arguments:

function toArray<T>(input: T): Array<T> {
return [input];
}

This function can be read as: "The function toArray receives an argument of type T and returns an array of that same type T."

It is common for developers to use single letters to name their generics, although this is not necessary. You can use whatever name you want that makes sense for your code.

In the same example you can see a second use of a generic, in this case a native type helper.

The return type of the function is annotated as Array. This is a global or native type offered by Typescript that helps you to define an array of some type. The “some type” part of the sentence is the Generic. In this case, the Array helper is using a generic argument named T, so this toArray function is returning an array of the type passed as the generic.

For example:

toArray(1) // Array<number>
toArray({ a: "this is an object"}) // Array<{ a: "this is an object"} >

It's worth noting that the Array type helper is a generic object that can also be used by its shorthand [], so Array<number> is the same as number[].

In summary, a Generic is a representation of a type value that is not yet present. You can use the same mental model that you have for a function argument in Javascript. The argument will take a value when the function is called, and the same is true for Generics.

How can you use generics

The first step in your Typescript journey is to learn about the basic types to start annotating your code. After that, you can learn some of the type helpers offered by Typescript, as well as more advanced concepts such as keyof and extends.

I invite you to read two related articles: “What are the basic Typescript Types?” on my site and “Learn the Key Concepts of Typescript's Powerful Generics and Mapped Types” on egghead.

At this point, you are already using generics without even noticing. Most of the Typescript helpers are, in fact, implemented as a generic. Let's review some of them.

Utility Types

Typescript offers a number of utility types that are globally available as part of the language. These are used to perform basic data transformations from one type to another. These types are, in fact, generics, as you'll see in the examples below.

Partial and Required

The first type utility we will explore is Partial.

Partial is defined as type Partial<T> = { [P in keyof T]?: T[P] | undefined; }. This is a generic type that uses another feature called mapped type.

Mapped Types are another advanced feature of Typescript that will be covered in a future article.

This type takes a generic named T and transforms each property of that object T into an optional property, adding the ? symbol and transforming the value of that property into a union type between the original value and undefined.

type User = {
id: string;
email: string;
age: number
}
type PartialUser = Partial<User>
/*
{ id?: string | undefined; email?: string | undefined; age?: number | undefined}
*/

Required is the opposite – it takes a generic type (any type) and transforms each of the properties into a required property, that is, it removes the undefined part of the union and removes the optional symbol ?.

Omit and Pick

Omit is a utility type that takes two generics and removes properties from that object based on the second argument.

type Omit<T, K extends string | number | symbol> = { [P in Exclude<keyof T, K>]: T[P]; }

The first generic “argument” is named T and can be anything you want. The second parameter is named K and uses the extends keyword as a constraint for the generic. This means that the generic type K can only be of a type mentioned in that union.

type NewUser = Omit<User, "id"> // { id: string; email: string }

The opposite utility is Pick, which allows you to choose what you want to keep from an object type.

type NewUser = Pick<User, "string" | "email"> // { id: string; email: string }

In this example, both implementations of the NewUser type are the same.

These are just a few examples of utility types that can transform objects. There are more, and some that are tailored to handle union types. You can check out the Typescript Playground to get more information about each one.

Patterns that use Generics

The use of generics falls into some well-known design patterns, such as generic type helpers or type helpers. You can build your own type helpers by, for example, combining utility types.

type ReadOnlyNewUser = Readonly<Omit<User, "id">>

Another way to use generics in your own code is by creating generic functions, these are functions that are annotated with a generic, meaning that this function can take a generic type to perform its operations:

function typedFetch<Data>(url: string): Promise<Data> {
return fetch(url).then(res => res.json())
}
typedFetch<{ username: string; email: string}>('/someapi/endpoint').then((result) => {
console.log(result)
})

The example above shows the following:

  • typedFetch receives two arguments, one at the type level and one at the runtime. The type level argument is the generic <Data>, which is used to type the return type of the function : Promise<Data>.
  • When typedFetch is calls, you are passing a type argument inside the brackets <{ username: string, email: string}> that represents the generic value Data used in the function declaration.

So, typedFetch will return a promise with the data passed as generic argument. In this case it will return a Promise of an object with {username: string; email: string}.

Typescript uses this pattern to define its own functionality. Most of the JavaScript methods are annotated as typed functions, for example:

const map = new Map() // Map<any, any>

The Map object is typed as Map<any, any>, meaning that to create a Map you need to pass two arguments to it, which can accept any type. In fact, the Map definition looks like this (comments were stripped out):

interface Map<K, V> {
clear(): void;
delete(key: K): boolean;
forEach(callbackfn: (value: V, key: K, map: Map<K, V>) => void, thisArg?: any): void;
get(key: K): V | undefined;
has(key: K): boolean;
set(key: K, value: V): this;
readonly size: number;
}

This is a generic interface that takes two arguments K and V, which are used to define the different methods implemented in Map.

Another good example of generic functions are the React hooks:

function Component() {
const [myState, setMyState] = React.useState<number>(0)
function onClick(){
setMyState(n => n +1)
}
return <button onClick={onClick}>+1</button>
}

The component is using useState to handle a counter state. To get the correct type inference when using setMyState, the call to useState receives a generic that represents the type of the state that will be stored, in this case number.

Another reason to use generics is to improve the type inference offered by Typescript. The use of a generic argument when declaring a function helps Typescript to infer the correct types based on the information available:

function mergeObjects<ObjA, ObjB>(objA: ObjA, objB: ObjB) {
return {...objA, ...objB }
}
const result = mergeObjects({ a: 1, b: "b" }, { n:10, c: [1,2,3] })

Typescript is able to infer the correct type for the result variable, showing you that it is the intersection of the generics arguments used:

const result: {
a: number;
b: string;
} & {
n: number;
c: number[];
}

Here, when calling mergeObjects you’re not passing the type arguments, so there is missing information. However, Typescript is "clever enough" to look into the function values or runtime arguments to infer the type arguments.

Another important pattern is generic constraints. You should use this pattern most of the time to narrow the scope of the generic argument used. We already mentioned the usage of constraints in the Omit utility type definition.

When using generics, the default value for the generic argument defined is unknown. But, sometimes you need to narrow the scope of that type to be something in particular. For example, the ReturnType utility type works on function types. If you pass something else, it will not work as expected. The definition looks like this:

type ReturnType<T extends (...args: any) => any> = .....

The T generic argument is narrowed to be a type that belongs to the right side of extends. Here, extends is used to check if the generic argument is of a type that satisfies the type defined there. If it is not, Typescript will error, saying that the type does not satisfy the constraint.

You can also set default values to the type arguments. Similar to function arguments in pain ol' Javascript, you can define what will be the default value of a generic if the arguments are not passed to the function:

function createMap<K,V>() {
return new Map<K,V>()
}
const map1 = createMap<number, number>() //Map<number, number>
const map2 = createMap<string, number>() //Map<string, number>
const map3 = createMap() // Map<unknown, unknown>

The function createMap takes two type arguments. If they are not passed, the result will be Map<unknown, unknown> like the variable map3, so there won't be good type inference or autocompletion.

To meet the requirement that the createMap defaults to Map<string, string>, you can use default type values like this:

function createMap<K = string ,V = string>() {
return new Map<K,V>()
}
const map1 = createMap<number, number>() //Map<number, number>
const map2 = createMap<string, number>() //Map<string, number>
const map3 = createMap() // Map<string, string>

Generics can be used throughout your code, to create dynamic and reusable pieces of code in interfaces, classes, and functions, as shown in the above examples.

Conclusion

Generics are an important piece of Typescript features. They are mostly used by library authors to create really flexible and dynamic code. As an application developer you’ll mostly rely on the type inference provided by TS and the editor, but is worth to learn how to use Generics to solve more complex data requirements.

Generics, can look scary at first but with the proper mind set you can leverage its power in a profitable way.