Tech stack
·6 min read

Understanding TypeScript Generics: Why, How, and When to Use Them

As you dive deeper into TypeScript (TS) and become more accustomed to the basics of it, you’ll start to encounter more advanced features like generics. Generics are a very powerful feature with a lot of potential, but they also have a steep learning curve. In this post, we’ll cover all you need to know about generics, their benefits, and how to get started with them.

Prerequisites

There aren’t many prerequisites for learning generics, but I would highly recommend having a good grasp on the fundamentals of TypeScript. Generics can have a steep learning curve, and this will only be made steeper without a basic understanding of TypeScript.

If you would like to follow along with the code snippets and examples included in this post, then you can install TypeScript on your local machine and use a temporary project to experiment. Alternatively, a much easier way to follow along is to use the TypeScript playground, which requires no setup to use.

What are generics in TypeScript?

Generics are a TypeScript feature that allows us to pass in various types of data and create reusable code to handle different inputs. They allow us to define placeholder types which are then replaced when the code is executed with the actual types passed in.

Generics are like a template that can be reused across the same piece of code multiple times but with the value being independent of each invocation of the function. Let’s look at an example to get a better understanding of this.

// 👇 We define a generic value called T with <T>
function getFirstElement<T>(arr: T[]): T {
  return arr[0];
}

const numberArray: number[] = [1, 2, 3, 4, 5];
const stringArray: string[] = ['apple', 'banana', 'orange'];

// 👇 Note the generic values being passed in <number> & <string>
const firstNumber = getFirstElement<number>(numberArray);
const firstString = getFirstElement<string>(stringArray);

In this example, we define a function to retrieve the first value of a given array, but we don’t want to define the array type in the function as a fixed type like a number or string. So, instead, we define a generic (T), which is used to type the argument being passed in as well as the return value. This generic value will be replaced at runtime with a more accurate type which you can see when we call the function and pass in the values <number> and <string>.

Benefits of using TypeScript generics

Generics offer us multiple benefits as developers, but some of the biggest ones are.

  • Type Safety and Error Detection: Generics allow us to write code that operates on various types of data without losing the type safety of TypeScript.
  • Code Reusability & Flexibility: In a similar sense to the above point, generics help us make our code more reusable and flexible. This is because, with generics, we can abstract a piece of code to be more generic and work with multiple data types instead of just one, cutting down on the amount of duplicate code required to work with different data types.
  • Better Maintainability: Because generics allow us to write more reusable code, we now have fewer instances of code to maintain and update with bug fixes when required.

Using generics with interfaces

We can use generics with interfaces to define custom types for the properties of the object the interface describes. Here’s a basic example of using generics with an interface.

interface MetaData {
	sex: string;
	height: "tall" | "short";
	favouriteNumber: number;
}

// 👇 Defining our generic
interface Person<T> {
	id: number;
	name: string;
	age: number;
	metadata: T;
}

// 👇 Using our generic
const personOne: Person<(number|string)[]> = {
	id: 1,
	name: 'Jeff',
	age: 31,
	metadata: ['male', 'tall', 22]
}

// 👇 Using our generic
const personTwo: Person<MetaData> = {
	id: 1,
	name: 'Jeff',
	age: 31,
	metadata: {
		sex: 'female',
		height: 'tall',
		favouriteNumber: 45,
	}
}

In this example, we define an interface using a generic (Person), the generic type passed to this interface is then used to type the metadata property on it. We then have two examples, one where the metadata property is typed using an array of strings/numbers and another where it’s typed using a second interface called MetaData.

Both of these are valid examples because the data passed to the metadata property in each object aligns with the generic value being passed into the interface describing the object.

Using generics with types

Using generics with types is pretty similar to using interfaces and follows the same principles we covered above, but this time using the type syntax like so.

type MetaData = {
	sex: string;
	height: "tall" | "short";
	favouriteNumber: number;
}

type Person<T> = {
	id: number;
	name: string;
	age: number;
	metadata: T;
}

const personOne: Person<(number|string)[]> = {
	id: 1,
	name: 'Jeff',
	age: 31,
	metadata: ['male', 'tall', 22]
}

const personTwo: Person<MetaData> = {
	id: 2,
	name: 'Jess',
	age: 28,
	metadata: {
		sex: 'female',
		height: 'tall',
		favouriteNumber: 45,
	}
}

Using generics with classes

Much like we have done with interfaces and types, we can pass generics into a class to type methods and return values on the class. Here is an example of just that.

// 👇 Defining our new class with a generic
class ExampleClass<T> {
	// 👇 Initializing a new property with an array of the generic value
	private values: T[] = [];

	// 👇 Setting the argument for this method to be the generic
	setValue(value: T): void {
		this.values.push(value);
	}

	// 👇 Setting the return value of this method to be an array of the generic
	getValues(): T[] {
		return this.values;
	}
}

// 👇 Using 'number' as the type to replace our generic 'T'
const example = new ExampleClass<number>();

// 👇 We can now only pass in numbers. Any other type will error.
example.setValue(24);
example.setValue(42);
const values = example.getValues();

console.log(values); // [24, 42]

In this example, we define a new class that takes in a generic and uses that generic to type the property and methods that exist on it. We then initialize a new instance of that class with the type of number and successfully use the methods on it by passing in number values. However, if we were to pass in a string (or another non-number type) to this instance of the class, it would error as we’ve already told the class that the generic is a number.

Stay on Top of New Tools, Frameworks, and More

Research shows that we learn better by doing. Dive into a monthly tutorial with the Optimized Dev Newsletter that helps you decide which new web dev tools are worth adding to your stack.

Using generics with functions

Functions are one of the most common ways you’ll use generics in TypeScript. Let’s look at some examples of how we can use them.

interface ObjOne {
	prop1: string; 
	prop2: number; 
}

interface ObjTwo {
	prop3: string; 
	prop4: string; 
}


// Defining our function with a generic type (T)
async function fetchData<T>() {
	const response = await fetch('API_URL');
	// 👇 Telling TS that the response data is the type of T (our generic)
	const data = await response.json() as T;

	return data;
}

await fetchData<ObjOne>(); // The returned data would have a type of ObjOne
await fetchData<ObjTwo>(); // The returned data would have a type of ObjTwo

Here we define a new function that performs a fetch request to an API and then parses the resulting JSON using response.json(). The issue is TypeScript doesn’t know what type that JSON will be, so will default to using a type like any or unknown, but this isn’t very helpful for us. Luckily we can tell TypeScript what type the JSON will be by using a generic to type it for us via the as keyword.

Passing multiple generics

You’ll likely often encounter multiple generics when writing more complex functions being reused in multiple places with varying inputs. To define an extra generic, you can use a comma followed by the next generic placeholder.

// 2 Generics being defined (T & K)
function example<T, K>() {}

// Calling the function and passing types for the generics
example<string, number>()

Here is a complete example of combining two arrays that might have different types using multiple generics.

// Generic function with multiple generics
function mergeArrays<T, K>(arr1: T[], arr2: K[]): (T | K)[] {
  return [...arr1, ...arr2];
}

const numArray = [1, 2, 3];
const strArray = ["hello", "world"];
// 👇 You can explicity define the generics like this.
const mergedArray = mergeArrays<number, string>(numArray, strArray);

// 👇 Or, you can let TS infer the types like this but will work.
const mergedArray2 = mergeArrays(numArray, strArray);

console.log(mergedArray); // Output: [1, 2, 3, "hello", "world"]

Passing default types to generics

TypeScript also allows us to pass a default type to our generic. Default types allow generics to be optionally invoked without passing a type parameter, providing more flexibility in code reuse. For example:

type Person<Name extends string = string> = {
  name: Name // defaults to `string` if Name isn't provided
  age: number
}

type Alison  = Person<"Alison"> // => Alison["name"] is "Alison"
type Someone = Person           // => Someone["name"] is `string`

By doing this, TypeScript won’t expect a type to be provided to that generic all of the time and will allow you to omit it from your code when needed. Something to also consider is that you often won’t need to explicitly define a default type for a generic because TypeScript will infer its type from the value being passed into the generic when used with functions and classes.

Creating mapped types with generics

Mapped types are a feature of TypeScript that allows us to transform and/or manipulate the properties of an object type to return a different type. We can combine these with generics to create more powerful and flexible types for us to use, for example:

// 1️⃣ Original Type
type User = {
	id: number;
	name: string;
	age: number;
}

// 2️⃣ Mapped type using generics
type PartialUser<T> = {
	[K in keyof T]?: T[K]
}

// 3️⃣ Using our mapped type, valid syntax ✅
const partialUser: PartialUser<User> = {
	age: 22,
}

// 3️⃣ Using a normal type, invalid syntax ❌
// Error: Type '{ age: number; }' is missing the following properties from type 'User': id, name
const partialUser2: User = {
	age: 22,
}

In this example, we create a new mapped type (PartialUser), allowing us to create partial versions of the type passed into it. So, instead of needing to define every property on the object, we can only define the ones we need to. This can be seen in the two examples above, where the one using the mapped type is valid, and the example using the normal type is invalid.

This works because, in our mapped type, we take the generic type passed in (User) and loop over each key in the type ([K in keyof T]). Then we return a new version of that type where all the properties are optional (?: T[K]).

Creating conditional types with generics

Conditional types allow us to conditionally choose the right type based on a condition we define; we can take this one step further, however, and pair them with generics to allow us to write more reusable code. For instance, here is a basic example of using a conditional type with a generic.

interface Item {
	id: number;
	name: string;
}

type CheckType<T> = T extends Item ? Item : never;


const result2: CheckType<Item> = {
	id: 0,
	name: 'Jess'
};

// ❌ Error: Type 'string' is not assignable to type 'never'.
const result3: CheckType<string> = 'hello world';

Here we define a conditional type (CheckType) that takes in a generic and checks if it extends the Item interface. If it does, then we know it’s an Item type and return that. Otherwise, we return the type of never, which would throw an error as the type being passed into the generic cannot be assigned to never. This helps protect our code against types of data that shouldn’t be passed in.

Implementing generic constraints

Generic constraints are a method we can use to restrict the types that can be used as generic arguments; this helps us enforce specific rules on the types being passed into a function, for example. Here is an example of doing that.

function logName<T extends {name: string }>(obj: T) {
	console.log(obj.name)
}

logName({name: 'Coner'}) // 'Coner'
logName({name: 'Josh', age: 2}) // 'Josh'
logName({ age: 2}) // ❌ Error: Object literal may only specify known properties, and 'age' does not exist in type '{ name: string; }'

For this example, we define a function that logs out the name property of an object. But, to check the object passed in does have the name property, we extend the generic using <T extends {name: string }>. This means any object being passed in will be checked for the name property, and an error will be thrown if it doesn’t exist. This effectively limits the objects that can be passed into the function to only the ones that satisfy this constraint.

FAQs about TypeScript Generics

What is a generic in TypeScript?

A generic is a placeholder for a future type to be passed in. They’re like a template that can be reused multiple times, with different types being passed in each time.

Closing thoughts on TypeScript generics

Generics can be a really helpful tool when working with TypeScript and can help us write more reusable, flexible, and easier-to-maintain code. However, they do have an initial barrier to entry that might be difficult to overcome, but once you do, they open up a wide variety of possibilities and benefits in your code.

If you’d like to learn more about TypeScript generics, then make sure to check out the docs.

Article written by

Coner Murphy

Fullstack web developer, freelancer, content creator, and indie hacker. Building SaaS products to profitability and creating content about tech & SaaS.

More posts
Coner Murphy profile picture.

2 comments

manu

your article is really interesting, I'm inicializing with the web develover, I will like learning more this theme and conceps of programming in TypeScritp.
Reply·4 months ago

Armando

Great breakdown, very detailed and helpful.

Reply·1 month ago
Hit your website goals

Websites success stories from the Prismic Community

How Arcadia is Telling a Consistent Brand Story

Read Case Study

How Evri Cut their Time to Ship

Read Case Study

How Pallyy Grew Daily Visitors from 500 to 10,000

Read Case Study