Mastering TypeScript Generics

In this latest blog from the developers on our Product Traction team, they outline their best practices for mastering TypeScript Generics.

Mastering TypeScript Generics: A Guide to Paginated Return Types

In TypeScript, generics are a powerful tool that bring flexibility and reusability to your code. They allow developers to create components, functions, and data structures that work with multiple types while maintaining strong type definitions. If you’ve ever worked with C++ templates, you’ll find TypeScript generics somewhat familiar. They serve a similar purpose: making your code adaptable and reusable across different data types.

Generics allow you to define a type parameter in your code, which can later be replaced with a concrete type when you invoke or instantiate a generic class, function, or interface. But beyond the basic type flexibility, using generics also ensures that TypeScript enforces type safety, reducing the chances of runtime errors.

TypeScript Generics vs. C++ Templates

In many ways, TypeScript generics resemble the template functionality in C++. Just as templates allow you to write functions or classes that can work with any data type in C++, TypeScript generics allow you to define a flexible type that can be filled in at runtime with any data type.

The key difference? In TypeScript, generics are primarily for compile-time type checking, which means that TypeScript ensures type correctness before the code even runs. You won’t face the same level of performance implications that you might in C++.

Understanding TypeScript Generics in Practice

Let’s take an example where generics are especially useful: paginated return types.

When designing a pagination system, the return type typically contains a set of data and additional information such as a cursor or metadata. Using generics allows you to keep the return type flexible while still maintaining strong type definitions. Here's how:

  1. Define a generic cursor based on the keys of the data. This ensures that the cursor type is directly related to the type of data you are paginating.
  2. Use generics for the data itself, which can represent different types depending on the endpoint you're working with.
  3. Other properties like metadata can also be included to provide additional information about the pagination state.

Tying the Cursor to the Data Type

A key improvement here is ensuring that the cursor is tied to a key within the data structure itself. This allows the cursor to be flexible but also strongly typed according to the type of data being returned. For example, if the data type has an id field or a timestamp that’s used for pagination, the cursor will reflect that automatically.

Here’s how you can enforce that behavior in TypeScript:

interface PaginatedResponse<T, K extends keyof T> {

  cursor: T[K]; // Cursor is tied to a key within the data

  data: T[];

  totalCount: number;

  hasNextPage: boolean;

}

function fetchPaginatedData<T, K extends keyof T>(

  endpoint: string,

  cursor: T[K]

): Promise<PaginatedResponse<T, K>> {

  // API call logic to get paginated data

  return fetch(endpoint).then(response => response.json());

}

In this example:

  • T represents the generic data type.
  • K extends keyof T ensures that the cursor is tied to a key of the T type. This allows the cursor to be flexible based on the shape of the data being paginated.
  • cursor: T[K] means the cursor can be any value that exists as a key in the data type T (like an id or a createdAt timestamp, depending on your specific data structure).

This approach ensures that your pagination logic is both flexible and tightly coupled to the actual data structure, preventing potential mismatches between the cursor and the data type.

Building a Reusable Pagination System

By leveraging this technique, you can create a pagination system that can be reused across multiple endpoints, each returning different data types while still maintaining strong type safety. Here's an example in practice:

interface User {

  id: string;

  name: string;

}

interface Product {

  sku: string;

  title: string;

}

const userPagination = fetchPaginatedData<User, "id">("/users", "1234");

const productPagination = fetchPaginatedData<Product, "sku">("/products", "SKU1234");

In this case:

  • The User type uses id as its cursor.
  • The Product type uses sku as its cursor.
  • Both can be paginated using the same fetchPaginatedData function, but with different cursor and data types!

Why Generics Matter

TypeScript’s generics offer a flexible way to design systems that are both robust and adaptable to different data types. In the case of paginated data, they allow us to build a reusable system that can plug into multiple APIs while maintaining strong typing and reducing the risk of errors.

Generics empower developers to write scalable, maintainable code that adapts to future changes in their data models without sacrificing safety or performance. Tying the cursor directly to the data keys ensures your code is not just reusable, but also highly specific and type-safe. Once you’ve harnessed the power of TypeScript generics, you’ll find yourself writing more flexible and reliable code, especially in complex systems like pagination.


The Thin Air Labs Product Traction team provides strategic product, design and development services for companies of all sizes, with a specific focus on team extensions where they seamlessly integrate into an existing team. Whether they are deployed as a team extension or as an independent unit, they always work with a Founder-First Mindset to ensure their clients receive the support they need.

To learn more about our Product Traction service, go here.

Build what's next with us