TypeScript Generics: Striking the Right Balance

Posted by: Suprotim Agarwal , on 11/8/2023, in Category TypeScript
Views: 13328
Abstract: In the evolving landscape of TypeScript, Generics have emerged as a powerful tool, enabling developers to write flexible, type-safe, and reusable code. This tutorial delves into the essence of generics in TypeScript, guiding developers on their effective use. Through practical examples and insights, we will explore the golden rules, pitfalls, and advanced features of generics, ensuring that developers harness their full potential without overcomplicating their code.

TypeScript offers a powerful feature called Generics. Generics enables developers to write flexible and reusable code.

However, with Great Power comes Great Responsibility!

Overusing generics can at times lead to unnecessary complexity. Even the TypeScript Handbook warns against overusing generics. It states that while writing generic functions can be enjoyable, overindulgence can lead to complications in your code.

In this tutorial, let’s explore how to master the art of using Generics effectively in TypeScript.

typescript-generics-best-practices

Understanding the Essence of Generics

Generics in TypeScript serve as a concept that allows for the creation of components that can work over a variety of types rather than a single one. This not only enhances code reusability but also maintains type safety.

At its core, Generics are about creating a kind of placeholder—a type variable—that you can use in multiple places within your function, class, or interface. This placeholder will later be replaced with an actual type specified by the user of your function or class.

Without generics, you might have to write multiple versions of the same function or class to handle different data types. However with generics, you can write a single, generalized version that can handle any type, with the compiler keeping track of the types for you.

For instance, consider a function that can accept an array of any type and return the first element:

function getFirstElement<T>(arr: T[]): T {
    return arr[0];
}

Here’s what this function does:

  • T is a type variable. When you use the function, you can specify what T is (e.g., number, string, User, etc.).
  • arr: T[] means that the function expects an array of any type T.
  • The function returns a value of type T.

When you call getFirstElement, you can pass it an array of any type:

 

const numbers = [1, 2, 3];
const firstNumber = getFirstElement(numbers); // T is inferred to be number

const strings = ["hello", "world"];
const firstString = getFirstElement(strings); // T is inferred to be string

In these calls, TypeScript determines the type of T based on the type of the array passed in. The first call infers T to be number, and the second infers T to be string. This inference allows the function to return the correct type without any extra effort from the developer.

This simple example showcases the power of generics, allowing for flexibility without compromising type safety.

The Golden Principle of Generics

One of the most vital guidelines when working with Generics is often termed as the “Golden Rule of Generics”:

Every Type Parameter Should Appear More Than Once

If a type parameter appears only once, it doesn’t establish any relationship between types, making it redundant. This principle ensures that generics are used to create meaningful relationships between input and output types, or among multiple function parameters.

For example, consider the following function:

function firstElement<T>(items: T[]): T {
  return items[0];
}

Here, the type parameter T is used twice, once for the argument and once for the return type. This is a valid use of generics as it ensures the input and output types are identical.

Contrast this with a function that logs the first character of a string:

function firstChar<S>(str: S): void {
  console.log(str[0]);
}

In this case, S is used only once and doesn’t need to be generic. A better version would be:

function firstChar(str: string): void {
  console.log(str[0]);
}

Accessing Object Properties/Attributes with Generics

Look at this function that fetches an attribute’s value from a provided object:

function fetchAttribute<T, K extends keyof T>(entity: T, attribute: K) {
  console.log(entity[attribute]);
}

In the original version, T is used multiple times, but K is only used once. We can optimize this function as follows:

function fetchAttribute<T>(entity: T, attribute: keyof T) {
  console.log(entity[attribute]);
}

This revision is more succinct and retains type safety, ensuring that attribute is a legitimate key of entity.

However, when we want to preserve the type of the extracted attribute, generics are essential.

function retrieveValue<T, K extends keyof T>(entity: T, attribute: K): T[K] {
  return entity[attribute];
}

In this improved function, T[K] effectively uses both T and K, creating a type-safe linkage between the input object and the returned attribute value.

Let’s see an example which we may use in our everyday life. Let’s apply this principle to a Product object with name and price attributes:

interface Product {
  name: string;
  price: number;
}

const product: Product = {
  name: "Desk Lamp",
  price: 39.99
};

const productName = retrieveValue(product, "name"); // productName is inferred as string
const productPrice = retrieveValue(product, "price"); // productPrice is inferred as number

Here, retrieveValue not only returns the value of the specified attribute but also ensures the return type matches the attribute’s type, showcasing a judicious application of generics in TypeScript.

I made this statement a short while ago – “…when we want to preserve the type of the extracted attribute, generics are essential

What does it mean?

In TypeScript, when you access a property on an object, the type of that property is known at compile time. For example, if you have an object with a name property of type string and a price property of type number, TypeScript knows that accessing object.name will return a string and object.price will return a number.

However, when you write a function that could return any property from an object, without generics, you might lose this specific type information. The function would return a type that is too broad, like any or unknown, which doesn’t tell you anything about what type to expect.

Generics allow you to write a function that can still return the correct type information for the property it retrieves.

By using a generic type parameter for the property name (like K extends keyof T), and then using that to index into the object type (T[K]), you tell TypeScript to preserve the type information of the property being accessed. This way, the function retrieveValue can return a value of the correct type, whether it’s a string, number, or any other type, based on what property name is passed to it.

We have already seen an example of this:

function retrieveValue<T, K extends keyof T>(entity: T, attribute: K): T[K] {
  return entity[attribute];
}

// Assuming the same Product interface and product object from before:
const productName = retrieveValue(product, "name"); // Type is preserved as string
const productPrice = retrieveValue(product, "price"); // Type is preserved as number

In this example, retrieveValue is a generic function that returns the type of the property attribute from the object entity.

TypeScript can infer that productName is a string and productPrice is a number, because it looks at the type of product.name and product.price respectively, preserving the type specificity that would be lost without generics.

Determining Length of Array-like Objects

Let’s assume we need a function that determines the size of a collection that mimics the characteristics of an array; in other words, it has a length property:

function collectionSize<T extends { length: number }>(collection: T) {
  return collection.length;
}

In this example, T is only used to describe the type of collection, which is not the most efficient use of generics since we’re only extracting the length property.

A more direct and type-safe approach would be to use the ArrayLike type directly:

function collectionSize(collection: ArrayLike<any>) {
  return collection.length;
}

This function now accepts any object that has a length property, which is the essence of what an array-like structure is in TypeScript.

The ArrayLike<any> type is an existing TypeScript type that represents any object that has a length property and can be indexed via numbers, just like an array.

For example, if we have a NodeList from the DOM API, which is an array-like object, we can use our collectionSize function to get its size:

const nodeList = document.querySelectorAll('div');
const size = collectionSize(nodeList); // size is inferred as number

Here, collectionSize is straightforward and leverages TypeScript’s type system to ensure that the argument has a length property, without the need for a Generic type parameter.

Inferred Types and Generics

Sometimes, the use of generics might not be immediately obvious because TypeScript can infer types in certain contexts. This inferred type can be a valid use of generics.

Consider a function that extracts keys from an object and returns them:

function extractKeys<T>(obj: T): keyof T[] {
  return Object.keys(obj) as Array<keyof T>;
}

In this function, the type parameter T is used in the function argument and the return type.

However, the return type isn’t explicitly using T but is inferred as keyof T[]. This is a valid use of generics because the type parameter appears in the inferred return type, establishing a relationship between the input object and the returned keys.

For instance:

const user = {
  name: "Suprotim",
  age: 45
};
const keys = extractKeys(user); // keys is inferred as ("name" | "age")[]

Here, the extractKeys function returns an array of keys, and TypeScript infers the type of keys based on the user object.

Handling Structured Data

In the context of TypeScript, structured data refers to data that adheres to a specific shape or format, typically defined by interfaces or types. When we talk about “handling structured data,” we’re referring to the process of writing functions or methods that operate on this data in a type-safe manner.

The initial example provided uses a generic type U to define a function that returns a full name:

function getFullName<U>(user: U): string {
  return `${user.firstName} ${user.lastName}`;
}

However, this use of generics (U) is not ideal because it assumes that U has the properties firstName and lastName, but it doesn’t enforce this structure. This could lead to runtime errors if getFullName is called with an object that doesn’t have these properties, because TypeScript’s type checker won’t be able to catch this mistake at compile time.

A better approach is to define an interface that explicitly states the structure of the expected user object:

interface User {
  firstName: string;
  lastName: string;
}

function getFullName(user: User): string {
  return `${user.firstName} ${user.lastName}`;
}

With this interface in place, TypeScript knows that the getFullName function expects an object with firstName and lastName properties, both of which should be strings. This makes the function more predictable and safer to use. If you try to pass an object that doesn’t match the User interface, TypeScript will give you a compile-time error, preventing potential bugs.

This approach also improves code readability and maintainability. When another developer reads the getFullName function signature, they can immediately understand the kind of object it operates on without having to guess or look at the implementation details. It’s clear that the function expects a User object, and they can quickly find the User interface definition to see what constitutes a valid User.

Using interfaces to define the structure of data in TypeScript is a best practice because it provides explicit contracts for your functions and methods. It leverages TypeScript’s type system to ensure that the data being handled conforms to the expected structure, leading to more robust and maintainable code.

Classes and Role of Generics

In TypeScript, classes can also utilize generics to provide a way to create reusable and type-safe components. Let’s break down the two class examples provided to understand the role of generics in each case.

Storage Class with Generics

The Storage class is a generic class that manages a collection of items of type I. Here’s the class definition:

class Storage<I> {
  private items: I[] = [];

  add(item: I) {
    this.items.push(item);
  }

  get(index: number): I {
    return this.items[index];
  }
}

In this class, I is a generic type parameter that represents the type of items the storage will hold. This type parameter is used multiple times:

  • In the type annotation for the items array (I[]), which means the array will hold items of type I.
  • In the add method (add(item: I)), which specifies that it accepts an argument of type I.
  • In the get method (get(index: number): I), which indicates that it returns an item of type I.

The use of I in multiple places establishes a relationship between the type of the items stored and the type of items that are added or retrieved from the storage. This ensures type safety throughout the class operations. For example, if you create an instance of Storage<number>, you can only add numbers to it, and when you retrieve an item, you can be sure it will be a number.

Logger Class with and without Generics

Now, let’s look at the Logger class:

class Logger<L> {
  log(item: L) {
    console.log(item);
  }
}

Here, L is a generic type parameter that is used only once in the log method. The purpose of this generic is to allow any type to be logged. However, since it’s used only once, it doesn’t establish a relationship between different parts of the class or enforce any constraints. It’s essentially the same as using any, but with an unnecessary layer of complexity.

A simplified, non-generic version of the Logger class would be:

class Logger {
  log(item: any) {
    console.log(item);
  }
}

This version of the Logger class achieves the same functionality without generics. The log method can accept any type of argument, and it will simply print it to the console. The use of any here is appropriate because the log method does not need to enforce any type relationships or constraints—it’s just passing the item through to console.log.

As we have seen, the use of generics in the Storage class is justified because it enforces type safety across multiple methods and the internal state of the class.

In contrast, the Logger class does not benefit from generics because there is no need to enforce a type relationship, making the generic type parameter superfluous.

Constraints and Utility Types

In TypeScript, generics can be enhanced with constraints to enforce that the types passed to a generic function, class, or interface adhere to a specific structure or contract. This is done by extending a generic type with a particular shape or set of properties. Let’s dive into the concept of constraints and utility types.

Constraints in Generics

Constraints allow you to specify that a type parameter must have a certain set of properties. This is particularly useful when you want to access these properties within your generic function or class, and you want to ensure that they exist on the argument passed to it.

Here’s the given example with a constraint:

function logLastAccess<T extends { lastAccess: Date }>(entity: T) {
  console.log(`Last accessed on: ${entity.lastAccess.toISOString()}`);
}

In this function, T is a generic type that is constrained to types that have a lastAccess property of type Date. The extends { lastAccess: Date } part is the constraint. This ensures that the logLastAccess function can safely access the lastAccess property on entity and know that it is a Date object.

Here’s how you might use this function:

interface User {
  name: string;
  lastAccess: Date;
}

const user: User = {
  name: 'Jane Doe',
  lastAccess: new Date(),
};

logLastAccess(user); // Output: Last accessed on: [date string]

This example demonstrates how constraints can be used to ensure that a function can operate on a variety of types, as long as they meet certain criteria, thus preserving type safety and enhancing code reliability.

TypeScript also provides utility types like Partial<T>, Readonly<T>, and others, which are built using generics and offer powerful ways to manipulate types.

Key Takeaways so far

  • Type Inference: TypeScript’s ability to infer types can sometimes make the use of generics less obvious. Always consider inferred types when evaluating the necessity of generics.
  • Establishing Relationships: Even if a type parameter doesn’t appear explicitly multiple times, its presence in inferred types can establish necessary relationships.
  • Simplicity Over Complexity: While generics are potent, always ensure they enhance the code’s clarity and value. Prioritize readability and maintainability.

Conclusion

Generics in TypeScript offer a nice blend of flexibility and type safety, allowing developers to create robust applications.

While the power of Generics is undeniable, it’s imperative to use them cautiously, ensuring you do not get overwhelmed by it and try to keep the code as readable and maintainable as possible.

By adhering to the principles and guidelines outlined in this tutorial, I hope you as a developer can strike the right balance.

This article has been technically reviewed by Mahesh Sabnis.

This article has been editorially reviewed by Suprotim Agarwal.

Absolutely Awesome Book on C# and .NET

C# and .NET have been around for a very long time, but their constant growth means there’s always more to learn.

We at DotNetCurry are very excited to announce The Absolutely Awesome Book on C# and .NET. This is a 500 pages concise technical eBook available in PDF, ePub (iPad), and Mobi (Kindle).

Organized around concepts, this Book aims to provide a concise, yet solid foundation in C# and .NET, covering C# 6.0, C# 7.0 and .NET Core, with chapters on the latest .NET Core 3.0, .NET Standard and C# 8.0 (final release) too. Use these concepts to deepen your existing knowledge of C# and .NET, to have a solid grasp of the latest in C# and .NET OR to crack your next .NET Interview.

Click here to Explore the Table of Contents or Download Sample Chapters!

What Others Are Reading!
Was this article worth reading? Share it with fellow developers too. Thanks!
Share on LinkedIn
Share on Google+

Author
Suprotim Agarwal, MCSD, MCAD, MCDBA, MCSE, is the founder of DotNetCurry, DNC Magazine for Developers, SQLServerCurry and DevCurry. He has also authored a couple of books 51 Recipes using jQuery with ASP.NET Controls and The Absolutely Awesome jQuery CookBook.

Suprotim has received the prestigious Microsoft MVP award for Sixteen consecutive years. In a professional capacity, he is the CEO of A2Z Knowledge Visuals Pvt Ltd, a digital group that offers Digital Marketing and Branding services to businesses, both in a start-up and enterprise environment.

Get in touch with him on Twitter @suprotimagarwal or at LinkedIn



Page copy protected against web site content infringement 	by Copyscape




Feedback - Leave us some adulation, criticism and everything in between!