DEV Community

Cover image for Embracing TypeScript Generics: A Lighthearted Guide to Flexible and Robust Code
Alexander Opalic
Alexander Opalic

Posted on • Edited on

Embracing TypeScript Generics: A Lighthearted Guide to Flexible and Robust Code

Introduction 😃

Generic programming is a powerful paradigm that has revolutionised the way we write code, making it more reusable and flexible. According to Wikipedia, "Generic programming is a style of computer programming in which algorithms are written in terms of types to-be-specified-later that are then instantiated when needed for specific types provided as parameters." TypeScript, a popular superset of JavaScript, has embraced this concept of generics, enabling developers to create maintainable and scalable code with ease.

In this blog post, we will explore the world of TypeScript generics, shedding light on how they can enhance your programming experience and boost your code's efficiency. By understanding the fundamentals of generics, you will be better equipped to write clean, reusable code that can adapt to a wide range of scenarios. So, let's dive in and uncover the true potential of generics in TypeScript! 🚀

Hello world of generics identify function 🌐

To understand the basics of generics, let's explore a simple example - the "Hello World" of generics, the identity function.

The identity function is a function that takes a single argument and returns it unchanged. Without using generics, you could write separate identity functions for different types:

const identityNumber = (value: number): number => value;
const identityString = (value: string): string => value;

Enter fullscreen mode Exit fullscreen mode

However, this approach isn't scalable and leads to code duplication 😕. Instead, we can use generics to create a single, reusable identity function that works with any type:

const identity = <T>(value: T): T => value;
Enter fullscreen mode Exit fullscreen mode

In this example, is a type variable that represents a yet-to-be-determined type. By using in the function signature, we indicate that the identity function can work with any type T. When you call this function, TypeScript will infer the appropriate type based on the provided argument:

const result1: number = identity(42); 
// T is inferred to be 'number'
const result2: string = identity("Hello World"); 
// T is inferred to be 'Hello World'
Enter fullscreen mode Exit fullscreen mode

Alternatively, you can explicitly specify the type when calling the function:

const result3: number = identity<number>(42);
const result4: string = identity<string>("Hello World");
Enter fullscreen mode Exit fullscreen mode

The key benefit of using generics in this case is that you can create a single, reusable function that works with multiple types while still maintaining type safety 👍.. Instead of writing separate functions for each type, you can use the generic identity function to handle various types without duplicating code or sacrificing type information.

A Gentle Reminder: You're Already Familiar with Generics! 😉

Believe it or not, you've likely been using generics without even realising it! For instance, when working with arrays in TypeScript, you're already making use of the power of generics. Consider the following simple example:

const bla: Array<string> = ['a', 'b', 'c'];
Enter fullscreen mode Exit fullscreen mode

In this case, TypeScript understands that bla is an array of strings, thanks to the type annotation Array. The Array type is actually a generic type, with being the type argument that specifies the type of elements within the array.

The beauty of TypeScript is that it's often capable of inferring the correct type even if you don't explicitly provide it. For example:

const bla = ['a', 'b', 'c'];
Enter fullscreen mode Exit fullscreen mode

Without specifying the type, TypeScript will still recognise that bla is an array of strings 🧙‍♂️. This is because TypeScript's type inference system examines the array elements and automatically infers that they are all strings.

So, even if you're new to the concept of generics, remember that you've already been reaping their benefits in your TypeScript code through everyday constructs like arrays! 🎉

A Practical Example: Loading a Pokémon with Generics

Now that you've grasped the basics, let's dive into a more practical example to harness the full power of generics. This is a use case I frequently encounter in my work.

Suppose you're tasked with creating a UI for a Pokédex, and you need to load information about a Pokémon when a user clicks on it. A simple function to accomplish this might look like:

const loadAPokemon = async (id: number) => {
   const url = `https://pokeapi.co/api/v2/pokemon/${id}`;
   const response = await fetch(url);
   const pokemon = await response.json();
   return pokemon;
};
Enter fullscreen mode Exit fullscreen mode

Examining the type signature of loadAPokemon, you would see:

const loadAPokemon: (id: number) => Promise<any>
Enter fullscreen mode Exit fullscreen mode

In real-world scenarios, you would likely know the expected response structure from the backend, and using any as a return type is not particularly helpful 🤔.

This is where the power of generics comes into play. First, let's define the return type of a Pokémon:

type Pokemon = {
 name: string;
 id: number;
 type: "grass" | "fire" | "water";
 attacks: {
   first: string;
   second: string;
 };
};
Enter fullscreen mode Exit fullscreen mode

This simple type represents the response we expect to receive from the Pokémon API.

Next, let's update our function to include generics, providing a more precise return type: ✨

const loadAPokemon = 
async <PokemonType>(): Promise<PokemonType> => {
  // we already know the implementation
};
Enter fullscreen mode Exit fullscreen mode

Now one of the main benefits is that it prevents you for typos see 🚀

const main = async () => {
  const myPokemon = await loadAPokemon<Pokemon>(1);
  console.log(myPokemon.name); // Correct property
  console.log(myPokemon.name2); // Typo in the property name
};
Enter fullscreen mode Exit fullscreen mode

Enhancing the loadAPokemon Function with Extends 🌟

We can further improve the loadAPokemon function by utilising the extends keyword in TypeScript. This ensures that the generic type provided as an argument to the function adheres to a specific structure or base type. This enhancement can help increase type safety and prevent incorrect usage of the function.

First, let's create a base type for our Pokémon:

interface PokemonBase {
  id: number;
  name: string;
}
Enter fullscreen mode Exit fullscreen mode

Now, let's update the loadAPokemon function to use the extends keyword, ensuring that the provided PokemonType extends PokemonBase:

const loadAPokemon = async <PokemonType extends PokemonBase>(
  id: number
): Promise<PokemonType> => {
  // we already know the implementation
};
Enter fullscreen mode Exit fullscreen mode

By using extends, we are enforcing that the generic type PokemonType must have at least the properties defined in the PokemonBase interface. This constraint guarantees that the function will only be used with types that meet the basic requirements of a Pokémon object.

For example, if we try to use the function with an incorrect type, TypeScript will show an error:

type InvalidPokemon = {
  id: number;
  // Missing 'name' property
};

const main = async () => {
  const myPokemon = 
await loadAPokemon<InvalidPokemon>(1); 

// Error: Property 'name' is missing 
// in type 'InvalidPokemon' 
// but required in type 'PokemonBase'.
};
Enter fullscreen mode Exit fullscreen mode

By leveraging the extends keyword, we can make the loadAPokemon function more robust and type-safe, ensuring that it is used with appropriate types and preventing potential bugs in the code.

In conclusion, the extends keyword is a powerful feature that can help enforce constraints on generic types, making your TypeScript code even more reliable and maintainable. Combining this with the versatility of generics, you can create highly flexible and type-safe functions for a wide range of use cases.

Using KeyOF to even make more generic functions 🔑

Lets imagine we have the following type

interface Pokemon {
 name: string;
 type: string;
 level: number;
}
Enter fullscreen mode Exit fullscreen mode

and we will use it like that to store an Array of pokemeons

const pokemonTeam: Pokemon[] = [
 { name: "Pikachu", type: "Electric", level: 12 },
 { name: "Charmander", type: "Fire", level: 8 },
 { name: "Squirtle", type: "Water", level: 10 },
];
Enter fullscreen mode Exit fullscreen mode

Now lets say somewhere you need to filter out a specific pokemon out of that so you maybe would have a function and call it like that

const pikachu = 
findObjectByProperty(pokemonTeam, "name", 'Pikachu');
Enter fullscreen mode Exit fullscreen mode

With the help of keyOf and generics we can even make this function type safe so that we would get an error if we search for a key that is not in our type

This is how the function would look in Typescript

function findObjectByProperty<T extends { [key: string]: any }>(
 arr: T[],
 prop: keyof T,
 value: T[keyof T]
): T | undefined {
 return arr.find((obj) => obj[prop] === value);
}
Enter fullscreen mode Exit fullscreen mode

Now, let's break down this implementation:

The function signature includes a generic type T that extends an object with a string index signature
({ [key: string]: any }). This ensures that the generic type T is an object with keys of type string.

The function accepts three parameters:

  • arr: An array of objects of type T.
  • prop: A property key of the object, which is defined as keyof T. The keyof keyword retrieves the keys of the object type T and ensures that prop is one of the valid keys.
  • value: The value to search for within the specified property. The type of this value is T[keyof T], which means it can be any value of the properties in type T.

The function returns an object of type T if found, or undefined if not found. It uses the Array.prototype.find() method to iterate through the array and return the first object that matches the specified property and value.

With this implementation, you can now use the findObjectByProperty() function to search for a specific Pokémon in a type-safe manner:

const pikachu = 
findObjectByProperty(pokemonTeam, "name", "Pikachu");

const electricPokemon = 
findObjectByProperty(pokemonTeam, "type", "Electric");

const level10Pokemon = 
findObjectByProperty(pokemonTeam, "level", 10);

// If you try to search for an invalid property, 
// TypeScript will show an error:
const invalidSearch = 
findObjectByProperty(pokemonTeam, "invalidProperty", "test");

// Error: Argument of type '"invalidProperty"' 
// is not assignable to parameter of type 
'"name" | "type" | "level"'.
Enter fullscreen mode Exit fullscreen mode

Conclusion 🎓

Generics in TypeScript enable developers to write reusable and type-safe code, increasing the flexibility and maintainability of your applications. 🌟 By understanding and leveraging the power of generics, you can create robust solutions that adapt to various scenarios while maintaining type safety. 💪

In this blog post, we explored the basics of generics, practical examples of their usage, and advanced concepts such as the extends keyword and keyof. 🧩 By incorporating these techniques into your TypeScript projects, you can enhance your programming experience 😃 and create more efficient, flexible, and maintainable code. 🛠️

Top comments (0)