🎁 Give the #1 gift request of 2024... a ZTM membership gift card! 🎁

TypeScript Generics Explained: Beginner’s Guide With Code Examples

Jayson Lennon
Jayson Lennon
hero image

Generics. What are they, what do they do, and why should you care?

Good questions! If you've ever found yourself repeating code for different data types, generics can help you streamline your development process. They are an incredibly powerful tool that allows developers to write flexible, reusable, and type-safe code.

In this guide, we'll explore the fundamentals of generics in TypeScript, why they are essential, and how you can effectively use them in your projects.

So whether you're a beginner TypeScript developer, or looking to refine your TypeScript skills, this guide will provide you with the knowledge you need to leverage generics effectively.

Let’s get into it…

Sidenote: If you're struggling to figure out generics and how to use them, want to learn more, or simply just want a deeper dive into TypeScript, then check out my complete course on Typescript here.

learn typescript

It takes you from a complete beginner to building real-world applications and being able to get hired, so there's something for every level of TypeScript user.

Check it out above, or watch the first videos here for free.

With that out of the way, let's get into arrays in TypeScript and how they work...

Why do we need Generics in TypeScript?

To understand the benefits of TypeScript generics, we need to remember one of the core principles of programming languages, which is DRY or ‘Don't Repeat Yourself’.

The reason this is a core principle is because duplication of code is problematic in software engineering. It increases complexity and raises the risk of introducing errors, and inevitably, the cost of maintenance.

Now, one of the beauties of TypeScript is its ability to catch mistakes while you're writing code, thanks to the static type-checking feature. However, when we want our function or class to handle multiple data types, we often end up repeating our code for each data type, violating the DRY principle.

Naughty I know.

For example

function logNumber(input: number) {
  console.log(input);
}

function logString(input: string) {
  console.log(input);
}

// ... and so on for each data type

The above code works, but it starts to become unmanageable after handling just a few different data types.

Why? Well for each new type, you have to write a new function, which not only bloats your codebase but also increases the risk of errors and makes maintenance more challenging.

too many functions

This is where generics come into play.

By using generics, you can write a single function or class to handle multiple data types, thus adhering to the DRY principle and making your code more maintainable and scalable.

Let's break this down a little further...

What exactly are TypeScript Generics?

Generics in TypeScript allow you to define a placeholder for a type that you can specify later. (The term 'generic' literally refers to the ability to create a ‘generic’ component that can work with any data type that is not tied to a specific one).

This flexibility enables you to write functions, interfaces, and classes that can operate with various data types without sacrificing type safety.

For example

Here you can see a basic generic function:

function log<T>(input: T): void {
  console.log(input);
}

log("Hello, TypeScript Generics!"); // prints: Hello, TypeScript Generics!
log(2022); // prints: 2022

In the generic function above, <T> is called a "generic type parameter" and acts as a placeholder for any data type. When you call the log function with different types of arguments, TypeScript ensures that the type is compatible with the function definition.

To help illustrate this, imagine that a distinct function is called for each input data type.

For example

If we call log with a string like this:

log("hello");

It's conceptually similar to calling a non-generic function like this:

function logString(input: string) {
  console.log(input);
}

This process happens at compile-time, so you end up with only one function that can work with multiple data types.

TL;DR

Using generics allows us to write more flexible and reusable code. For instance, our generic log function can handle any type specified by <T>.

This is great for functions like log because it allows us to log anything. However, there are scenarios where we want to restrict the types our function can accept, and that's where generic constraints come in.

An introduction to Generic Constraints

Using generics with type parameters that can be any type is powerful, but there are cases when we want to limit these types to ensure they meet certain criteria.

This is where generic constraints come in.

Constraints allow us to specify that a generic type parameter must conform to a particular structure or interface.

For example:

interface HasId {
  id: number;
}

function getItemById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

If we break down the code above, we’re defining an interface HasId with a single property id of type number. The generic function getItemById uses a type parameter T that extends HasId.

This means that T can be any type that has an id property of type number.

Now, let's see this function in action:

const users = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' },
  { id: 3, name: 'Charlie' },
];

const user = getItemById(users, 2);
console.log(user); // Output: { id: 2, name: 'Bob' }

In this example, users is an array of objects where each object has an id and a name. When we call getItemById with this array and an id value, it correctly finds and returns the object with the matching id.

If we try to use a type that doesn't satisfy the HasId interface, TypeScript will give us an error:

const error1 = getItemById("nope", 2); // Argument of type 'string' is not assignable to parameter of type 'HasId[]'
const error2 = getItemById(9, 2); // Argument of type 'number' is not assignable to parameter of type 'HasId[]'

This error occurs because the string "nope" and the number 9 do not meet the requirement of having an id property.

So, generic constraints ensure that our generic functions work only with types that match specific criteria, adding an extra layer of type safety and making our code more robust.

Sidenote: For more info on handling various data structures in TypeScript, check out my guide on TypeScript Union Types, which explores another great feature for managing multiple types effectively.

An introduction to Generic TypeScript interfaces

Just like functions, TypeScript interfaces can also leverage generics to enhance their flexibility and reusability.

What like? Well, using generics with interfaces allows you to define interfaces that work with a variety of data types, making your code more adaptable and type-safe.

For example

Let's start by defining a simple generic interface, like so:

interface Container<T> {
  value: T;
}

Here, Container is a generic interface with a type parameter T, and can now be used to create containers for different types:

const stringContainer: Container<string> = { value: 'Hello' };
const numberContainer: Container<number> = { value: 42 };

With this setup, you can create Container instances for any type without repeating code. This generic approach further reduces redundancy and increases the reusability of your code.

Using Multiple Type Parameters

You can also define interfaces with multiple type parameters, which is useful when you need to specify more than one data type.

For example

interface Pair<K, V> {
  key: K;
  value: V;
}

The Pair interface uses two type parameters, K and V, representing the types of the key and value, respectively. You can also create instances of this interface for different combinations of types, like so:

const numberStringPair: Pair<number, string> = { key: 1, value: 'one' };
const stringBooleanPair: Pair<string, boolean> = { key: 'isActive', value: true };

Using multiple type parameters adds even more flexibility, allowing you to define complex data structures that can adapt to various types.

For example

Consider a scenario where you are dealing with API responses. You could define a generic interface to represent the API response structure:

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

Now, you can use this interface to type the responses for different data types:

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

const userApiResponse: ApiResponse<User> = {
  data: { id: 1, name: 'Alice' },
  status: 200,
  message: 'Success',
};

interface Product {
  id: number;
  title: string;
  price: number;
}

const productApiResponse: ApiResponse<Product> = {
  data: { id: 1, title: 'Laptop', price: 999 },
  status: 200,
  message: 'Success',
};

In this example, ApiResponse is a generic interface that can handle responses for any type of data, making it a versatile and reusable component in your TypeScript codebase.

An introduction to Generic TypeScript Classes

Generic classes allow you to define classes that can work with a variety of types, providing the same flexibility and reusability benefits as generic functions and interfaces.

The good news also is that writing generic classes in TypeScript is similar to writing generic interfaces.

For example

interface HasId {
  id: number;
}

class Repository<T extends HasId> {
  private items: T[] = [];

  addItem(item: T): void {
	this.items.push(item);
  }

  findById(id: number): T | undefined {
	return this.items.find(item => item.id === id);
  }
}

Here the Repository class is defined with a generic type parameter T that extends the HasId interface. This constraint ensures that the Repository can only store items that have an id property.

Using the Generic Class

You can also create instances of the Repository class for different types that satisfy the HasId constraint:

interface User extends HasId {
  name: string;
}

const userRepository = new Repository<User>();
userRepository.addItem({ id: 1, name: 'Alice' });
userRepository.addItem({ id: 2, name: 'Bob' });

console.log(userRepository.findById(1)); // Output: { id: 1, name: 'Alice' }

In this example, User extends the HasId interface, so it can be used with the Repository class, while the userRepository instance can add and retrieve users by their id.

Generic Class Methods

Inside the class, you can also use the generic type parameter T anywhere that a type is allowed. This includes method parameters, return types, and property types.

Even better still, is the fact that you don't need to specify <T> on the methods again because it is already mentioned on the class itself.

For example

Here's a more complex example that demonstrates using generics with class methods:

class DataManager<T> {
  private data: T[] = [];

  addData(item: T): void {
	this.data.push(item);
  }

  getData(index: number): T | undefined {
	return this.data[index];
  }

  updateData(index: number, item: T): void {
	if (index >= 0 && index < this.data.length) {
  	this.data[index] = item;
	}
  }

  removeData(index: number): void {
	if (index >= 0 && index < this.data.length) {
  	this.data.splice(index, 1);
	}
  }
}

This DataManager class can handle a list of any type of data, with methods to add, get, update, and remove items. While the use of generics ensures that all operations are type-safe and work consistently with the specified type T.

Pretty handy right?

Another practical example of using generics in classes is a stack data structure.

For example

class Stack<T> {
  private elements: T[] = [];

  push(element: T): void {
	this.elements.push(element);
  }

  pop(): T | undefined {
	return this.elements.pop();
  }

  peek(): T | undefined {
	return this.elements[this.elements.length - 1];
  }

  isEmpty(): boolean {
	return this.elements.length === 0;
  }
}

const numberStack = new Stack<number>();
numberStack.push(10);
numberStack.push(20);
console.log(numberStack.pop()); // Output: 20
console.log(numberStack.peek()); // Output: 10

const stringStack = new Stack<string>();
stringStack.push('a');
stringStack.push('b');
console.log(stringStack.pop()); // Output: 'b'
console.log(stringStack.peek()); // Output: 'a'

In this example, the Stack class uses a generic type parameter T, allowing you to create stacks for different types of elements.

While the methods push, pop, peek, and isEmpty work with the type T, ensuring type safety throughout the class.

What are you waiting for? Add Generics to your own code today!

Hopefully this quick intro to generics has helped answer any questions you might have had so far.

Generics make your code flexible, reusable, and type-safe, while also helping you avoid the mess of duplicating code for different data types. So whether you’re dealing with collections, building APIs, or designing data structures, generics ensure your code is robust and maintainable.

If you haven't tried generics yet, definitely give them a shot. They’ll save you time and headaches, and make your code cleaner and more efficient.

P.S.

If you struggled to grasp some of the concepts around generics in TypeScript and how to use them in this guide, or just want a deeper dive into TypeScript, then check out my complete course on Typescript here.

learn typescript

You can even watch the first few lectures here for free.

The best part? When you join, you'll have direct access to me, other students, as well as full-time TypeScript devs in our private Discord channel - so you'll never be stuck!

BONUS: More TypeScript tutorials, guides & resources

If you've made it this far, you're clearly interested in TypeScript so definitely check out all of my TypeScript posts and content:

More from Zero To Mastery

Type Checking In TypeScript: A Beginners Guide preview
Type Checking In TypeScript: A Beginners Guide

What if you could catch errors at both compile and runtime? Thanks to TypeScript's type checking feature, you can! Learn how in this guide, with code examples.

TypeScript Union Types: A Beginners Guide preview
TypeScript Union Types: A Beginners Guide

Just what are Union Types in TypeScript? How do they work, what are they for, and how do you use them? Learn all this and more, with code examples ⚡

TypeScript Utility Types: A Beginners Guide (With Code Examples) preview
TypeScript Utility Types: A Beginners Guide (With Code Examples)

Want to improve your TypeScript skills? Learn how to use Utility types to streamline the development of your current and future programs. (With code examples!)