2025-04-25
/
Building Cleo

All types welcome: An overview of TypeScript Generics

Tara outlines how to use TypeScript generics to keep your code robust and reliable.

Text reads: An Overview of typescript generics
IN THIS ARTICLE:

Let’s face it — TypeScript is amazing, but sometimes it feels like you need a crystal ball to predict what data types your code might need down the road. Enter generics, your new best friend for writing code that’s both flexible and future-proof.

So, what exactly is a generic? In TypeScript, generics are like the ultimate multi-tool: they let you create reusable components that adapt to any data type without sacrificing TypeScript’s signature type safety.

Think of it this way: instead of locking yourself into a specific type from the start, generics act as placeholders that you can fill in when you actually know what your code will need to deal with. The result? Functions, classes, interfaces, and methods that are type-agnostic and ready to handle whatever data comes their way:

To truly appreciate generics, let’s take a moment to imagine life without them.

Without generics, you can find yourself writing repetitive code — components that look almost identical but differ just slightly in their type annotations. It’s tedious, inefficient, and a nightmare to maintain.

The alternative isn’t much better. Sure, you could create reusable shared components and methods, but if you resort to typing everything as any, you might as well toss the benefits of a typed language like TypeScript’s out the window.

And worst-case scenario? Overly complex type annotations that are as fragile as they are unreadable. Good luck trying to maintain those six months down the line!

The ultimate farmhand for your code

To make this more relatable, let’s take a trip to the farm. Yes, the farm—because nothing illustrates TypeScript generics better than cows, chickens, and sheep.

Problem 1: Repetitive code

Imagine you’re a farmer managing different types of animals. You need to track milk for cows, eggs for chickens, and wool for sheep. Without generics, you’d be stuck writing separate, nearly identical code for each animal — tedious and error-prone. Generics let you create a single, reusable solution for all your farm friends.

Problem 2: Lack of type safety

Now imagine if you threw all your farm data into a single bucket, regardless of type. Milk, eggs, and wool all mixed up — chaotic, right? That’s what happens when you sacrifice type safety. Generics keep things organised, ensuring each animal’s data stays neatly typed and manageable.

Problem 3: Hard-to-maintain code

As your farm grows and you add more animals, managing separate, specific code for each becomes unsustainable. Generics make your code flexible, so you can easily expand without a complete rewrite.

A world without generics

Now, let’s take a look at the before—what your farm looks like without generics.

You’ve got three different functions, all doing essentially the same thing, but each tailored to a specific type of animal. One function to handle cows, another for chickens, and yet another for sheep. It works, but it’s repetitive, clunky, and cries out for a better solution.

Here’s an example:

Three functions, one problem: they’re practically identical, except for the type of the data they deal with.

So what if we tried to combine these functions into one unified method?

At first glance, it seems like progress. You’ve got a single function that adapts based on the endpoint and can handle multiple types of farm data. But there are still major drawbacks:

  1. Type Safety Takes a Hit
  2. Despite handling multiple types, the function’s return type is still Promise<any>. This means you lose the ability to leverage TypeScript’s type checking, leaving you vulnerable to runtime errors.
  3. Maintenance Is Fragile
  4. As your farm grows, adding more endpoints and types makes the function increasingly complex. Every new animal adds another if branch, making it harder to maintain and much more prone to bugs.

While this approach is functional, it’s far from ideal. Clearly, we need a more robust solution. Enter generics.

Ready for the after?

Enter generics

It’s time to give our code a much-needed makeover — courtesy of our friend, generics.

This updated version uses a generic type parameter <T> to do all the heavy lifting. Let’s break down why this approach is a game changer:

  1. Flexible yet type-safe
  2. By specifying <T>, you ensure the function can work with any data type while maintaining strong type safety. When calling the function, you pass the exact type you expect (like Cow), and TypeScript ensures you can’t mix up your data types.
  3. Shared logic, single place
  4. The function consolidates all the shared logic for fetching data, error handling, and parsing JSON. This means you only need to update the fetch logic in one place if requirements change.
  5. Scalability made simple
  6. Adding a new animal type? No problem. Define the type (e.g., Pig) and call the same function with <Pig>. The function adapts seamlessly, without any code duplication or complex conditionals.

Thanks to generics, your code is now more concise, maintainable, and ready to scale. This is the power of TypeScript at its finest.

Other capabilities with generics

Setting a default type

When we specify <T = FarmAnimal>, we make it so that if no specific type is passed to the fetchFarmData function, T will default to FarmAnimal. The function has a return type of Promise<T[]>, which means the resolved value will be an array of type T. In practice, this means we’ll eventually end up with a list of objects with at least a name property.

But it’s important to remember that setting a default type does not enforce any type constraints. The method is still flexible enough that you can override the default by passing a specific animal type. Here’s what happens if, unlike our default FarmAnimal type, we call our method with a type that doesn’t have a name property:

Generic constraints

If you want to limit the types that a generic accepts, you can specify constraints. In our imaginary farm, we want to ensure the fetchFarmData function accepts types that satisfy the structure defined by FarmAnimal. We achieve this by using the extends key word: <T extends FarmAnimal> ensures that fetchFarmData will only accept animal types that have at least a name property. As a result, we know that animal.name is guaranteed to be safe, even when we don’t know what animal we’re dealing with.

So now if we try to call fetchFarmData with an invalid type, TypeScript will recognise this straightaway and let us know:

Conditional types

We can leverage conditional types to adapt the type of the data based on the endpoint string:

  • The generic type T is constrained to string, representing the endpoint
  • The conditional type AnimalType<T> checks the endpoint and returns the appropriate array type
    • If the endpoint string matches 'api/cows', the type resolves to an array of Cow.
    • If the endpoint string matches 'api/chickens', it resolves to an array of Chicken.
    • If the endpoint string matches 'api/sheep', it resolves to an array of Sheep.
  • If the endpoint doesn't match a known API path, never is returned, preventing invalid calls at compile time.

This use of conditional types allows the function to be highly adaptable based on the properties available for each type, leading to more flexible and type-safe code. It’s especially useful in scenarios where different animal types share some common properties but also have unique ones, allowing TypeScript to enforce the correct structure for each data type.

e.g.

What happens at runtime?

While conditional types can ensure type safety at compile time, they don’t actually exist at runtime. This means that if the API response doesn’t match expectations (whether that’s because of a bug on the backend or simply unexpected data), things can break.

To solve this, we can do some client-side validation before returning the data. We’re already using TypeScript types for compile-time safety, so let’s add some runtime type guards to our fetchFarmData function ensure the fetched data is in the expected format, and gracefully handle any unexpected data:

When should I be using generics?

Generics shine in scenarios where you need flexibility, reusability, and type safety across different parts of your codebase. Here are some key situations where they are particularly beneficial:

  • Reusable UI components: If you’re building components that should work with different types of data, like displaying user profiles or product details, generics allow you to define a single component that can accept any type of data, ensuring type safety without needing to rewrite the component for each different type.
  • Data fetching hooks: In data fetching hooks, such as those built with React Query or Axios, generics can help define the expected shape of the data returned from an API, making it easier to manage and handle response types consistently.
  • State management hooks: For hooks managing application state, generics can enforce the expected state structure, preventing runtime errors when accessing properties that may not exist in the state.
  • Error handling: In functions that need to handle different types of errors, generics allow you to define the expected error types up front, enabling type-safe error handling and making your error handling logic more predictable and easier to maintain.
  • Testing: Generics are invaluable in unit tests where you may want to test a function with different types of inputs. They ensure that test data conforms to expected shapes, which can lead to more reliable tests and fewer false positives.
  • Generic saga workers: For saga workers in Redux, generics can make it clear what action payloads are expected, leading to better type safety and less runtime error-prone code.
  • Navigation and API layers: When defining routes in a web application or API endpoints, generics ensure that the parameters and return types of these functions are correctly typed, providing a better developer experience and catching type-related issues at compile time.

Using generics in these contexts not only enhances type safety but also makes your codebase more adaptable and maintainable. They simplify how you manage data across different parts of your application and help you avoid common pitfalls related to type mismatches and runtime errors.

Rounding up the herd

Whether you’re wrangling cows, chickens, or any new critter that joins your “farm,” generics help keep your code robust and reliable. It's one of those more advanced concepts that definitely broadened my understanding of TypeScript — and it all started with a PR comment from a colleague less than a year ago. She asked if one of my components should be a generic, and in all honesty I had no idea what she meant. But now, I can see how they make code more flexible and type-safe. While I don’t use them all the time, I think they’re incredibly useful in the right places.

So if you didn’t know what generics were before, or if you’ve been curious about how to incorporate them into your projects, I hope this post gives you some ideas. Next time you’re building a reusable component, or building out an API layer, spare a thought for how generics could sprinkle a little magic into your code ✨

Happy coding — and happy farming!

FAQs
Still have questions? Find answers below.
Written by

Read more

Illustration of bugs
Building Cleo

How to fix bugs with Norse mythology

You didn’t hear it from me, but Cleo Engineers aren’t perfect. We have bugs. We have quite a few of them. Our relentless focus at Cleo is to make it as simple and joyful as possible for our users to level up their relationship with money.

2021-02-23

signing up takes
2 minutes

QR code to download cleo app
Talking to Cleo and seeing a breakdown of your money.