DEV Community

Cover image for Mastering TypeScript Generics: A Simple Guide
Hasan Moboarak
Hasan Moboarak

Posted on

Mastering TypeScript Generics: A Simple Guide

Generics in TypeScript allow you to create reusable components and functions that work with various data types while maintaining type safety. They let you define placeholders for types that are determined when the component or function is used. This makes your code flexible and versatile, adapting to different data types without sacrificing type information. Generics can be used in functions, types, classes, and interfaces.

Basic Syntax

The syntax for using generics involves angle brackets (<>) to enclose a type parameter, representing a placeholder for a specific type.

Using Generics with Functions

Here's how you can use generics in functions:

function identity<T>(arg: T): T {
  return arg;
}

const result = identity<string>("Hello, TypeScript!");
Enter fullscreen mode Exit fullscreen mode

In this example, the identity function uses a generic type parameter T to denote that the input argument and the return value have the same type. When calling the function, you provide a specific type argument within the angle brackets (<string>) to indicate that you're using the function with strings.

Passing Type Parameters Directly

Generics can also be useful when working with custom types:

type ProgrammingLanguage = {
  name: string;
};

function identity<T>(value: T): T {
  return value;
}

const result = identity<ProgrammingLanguage>({ name: "TypeScript" });
Enter fullscreen mode Exit fullscreen mode

In this example, the identity function uses a generic type parameter T, which is explicitly set to ProgrammingLanguage when the function is called. Thus, the result variable has the type ProgrammingLanguage. If you did not provide the explicit type parameter, TypeScript would infer the type based on the provided argument, which in this case would be { name: string }.

Another common scenario involves using generics to handle data fetched from an API:

async function fetchApi(path: string) {
  const response = await fetch(`https://example.com/api${path}`);
  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

This function returns a Promise<any>, which isn't very helpful for type-checking. We can make this function type-safe by using generics:


type User = {
  name: string;
};

async function fetchApi<ResultType>(path: string):Promise<ResultType> {
  const response = await fetch(`https://example.com/api${path}`);
  return response.json();
}

const data = await fetchApi<User[]>('/users');
Enter fullscreen mode Exit fullscreen mode

By turning the function into a generic one with the ResultType parameter, the return type of the function is now Promise<ResultType>.

Default Type Parameters

To avoid always specifying the type parameter, you can set a default type:

async function fetchApi<ResultType = Record<string, any>>(path: string): Promise<ResultType> {
  const response = await fetch(`https://example.com/api${path}`);
  return response.json();
}

const data = await fetchApi('/users');

console.log(data.a);
Enter fullscreen mode Exit fullscreen mode

With a default type of Record<string, any>, TypeScript will recognize data as an object with string keys and any values, allowing you to access its properties.

Type Parameter Constraints

In some situations, a generic type parameter needs to allow only certain shapes to be passed into the generic. To create this additional layer of specificity to your generic, you can put constraints on your parameter. Imagine you have a storage constraint where you are only allowed to store objects that have string values for all their properties. For that, you can create a function that takes any object and returns another object with the same keys as the original one, but with all their values transformed to strings this function will be called stringifyObjectKeyValues.

This function is going to be a generic function. This way, you are able to make the resulting object have the same shape as the original object. The function will look like this:

function stringifyObjectKeyValues<T extends Record<string, any>>(obj: T) {
  return Object.keys(obj).reduce((acc, key) =>  ({
    ...acc,
    [key]: JSON.stringify(obj[key])
  }), {} as { [K in keyof T]: string })
}
Enter fullscreen mode Exit fullscreen mode

In this code, stringifyObjectKeyValues uses the reduce array method to iterate over an array of the original keys, stringifying the values and adding them to a new array.

To make sure the calling code is always going to pass an object to your function, you are using a type constraint on the generic type T, as shown in the following highlighted code:

function stringifyObjectKeyValues<T extends Record<string, any>>(obj: T) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

extends Record<string, any> is known as generic type constraint, and it allows you to specify that your generic type must be assignable to the type that comes after the extends keyword. In this case, Record<string, any> indicates an object with keys of type string and values of type any. You can make your type parameter extend any valid TypeScript type.

When calling reduce, the return type of the reducer function is based on the initial value of the accumulator. The {} as { [K in keyof T]: string } code sets the type of the initial value of the accumulator to { [K in keyof T]: string } by using a type cast on an empty object, {}. The type { [K in keyof T]: string } creates a new type with the same keys as T.

The following code shows the implementation of your stringifyObjectKeyValues function:

function stringifyObjectKeyValues<T extends Record<string, any>>(obj: T) {
  return Object.keys(obj).reduce((acc, key) =>  ({
    ...acc,
    [key]: JSON.stringify(obj[key])
  }), {} as { [K in keyof T]: string })
}

const stringifiedValues = stringifyObjectKeyValues({ a: "1", b: 2, c: true, d: [1, 2, 3]})

*/
{
  a: string;
  b: string;
  c: string;
  d: string;
}*/
Enter fullscreen mode Exit fullscreen mode

Using Generics with Interfaces, Classes, and Types

When creating interfaces and classes in TypeScript, it can be useful to use generic type parameters to set the shape of the resulting objects. For example, a class could have properties of different types depending on what is passed in to the constructor. In this section, you will see the syntax for declaring generic type parameters in classes and interfaces and examine a common use case in HTTP applications.

Generic Interfaces and Classes

Interfaces:
interface MyInterface<T> {
  field: T
}
Enter fullscreen mode Exit fullscreen mode

This declares an interface that has a property field whose type is determined by the type passed in to T.

Classes:
class MyClass<T> {
  field: T
  constructor(field: T) {
    this.field = field
  }
}
Enter fullscreen mode Exit fullscreen mode

One common use case of generic interfaces/classes is for when you have a field whose type depends on how the client code is using the interface/class. Say you have an HttpApplication class that is used to handle HTTP requests to your API, and that some context value is going to be passed around to every request handler. One such way to do this would be:

class HttpApplication<Context> {
  context: Context
    constructor(context: Context) {
    this.context = context;
  }

  get(url: string, handler: (context: Context) => Promise<void>): this {
    return this;
  }
}
Enter fullscreen mode Exit fullscreen mode

This class stores a context whose type is passed in as the type of the argument for the handler function in the get method. During usage, the parameter type passed to the get handler would correctly be inferred from what is passed to the class constructor.

const context = { someValue: true };
const app = new HttpApplication(context);

app.get('/api', async () => {
  console.log(context.someValue)
});
Enter fullscreen mode Exit fullscreen mode

In this implementation, TypeScript will infer the type of context.someValue as boolean.

Generic Types

Generic types can be used to create helper types, such as Partial, which makes all properties of a type optional:

type Partial<T> = {   
 [P in keyof T]?: T[P]; 
};
Enter fullscreen mode Exit fullscreen mode

To understand the power of generic types, let's consider an example involving an object that stores shipping costs between different stores in a business distribution network. Each store is identified by a three-character code:

{
  ABC: {
    ABC: null,
    DEF: 12,
    GHI: 13,
  },
  DEF: {
    ABC: 12,
    DEF: null,
    GHI: 17,
  },
  GHI: {
    ABC: 13,
    DEF: 17,
    GHI: null,
  },
}

Enter fullscreen mode Exit fullscreen mode

In this object:

  • Each top-level key represents a store.
  • Each nested key represents the cost to ship to another store.
  • The cost from a store to itself is null.

To ensure consistency (e.g., the cost from a store to itself is always null and the costs to other stores are numbers), we can use a generic helper type.

type IfSameKeyThenTypeOtherwiseOther<Keys extends string, T, OtherType> = {
  [K in Keys]: {
    [SameKey in K]: T;
  } & {
    [OtherKey in Exclude<Keys, K>]: OtherType;
  };
};
Enter fullscreen mode Exit fullscreen mode
Breakdown this type
  1. Generics Declaration:
  • Keys extends string: Keys is a type parameter that must be a union of string literals. It represents all possible keys of the object.
  • T: A type parameter representing the type to be used when a key matches itself.
  • OtherType: A type parameter representing the type to be used when a key does not match itself.
  1. Mapped Type:
[K in Keys]:
Enter fullscreen mode Exit fullscreen mode

This is a mapped type that iterates over each key K in the union type Keys.

  1. Inner Object Type:

The inner object type is divided into two parts:

{
 [SameKey in K]: T;
}
Enter fullscreen mode Exit fullscreen mode

Here, [SameKey in K] creates a property where SameKey is exactly K. This means if the key of the outer object is K, this inner key is also K, and its type is T.

{
 [OtherKey in Exclude<Keys, K>]: OtherType;
}
Enter fullscreen mode Exit fullscreen mode

This part uses Exclude<Keys, K> to create properties for all other keys in Keys except K. The type of these properties is OtherType.

  1. Combining with Intersection (&):
{
  [K in Keys]: {
    [SameKey in K]: T;
  } & {
    [OtherKey in Exclude<Keys, K>]: OtherType;
  };
}
Enter fullscreen mode Exit fullscreen mode

The two inner object parts are combined using the intersection type &. This means the resulting type will include properties from both parts.

Example
type StoreCode = 'ABC' | 'DEF' | 'GHI';

type ShippingCosts = IfSameKeyThenTypeOtherwiseOther<StoreCode, null, number>;

const shippingCosts: ShippingCosts = {
  ABC: {
    ABC: null,    // T (null) because key is same as parent key
    DEF: 12,      // OtherType (number) because key is different
    GHI: 13       // OtherType (number) because key is different
  },
  DEF: {
    ABC: 12,      // OtherType (number) because key is different
    DEF: null,    // T (null) because key is same as parent key
    GHI: 17       // OtherType (number) because key is different
  },
  GHI: {
    ABC: 13,      // OtherType (number) because key is different
    DEF: 17,      // OtherType (number) because key is different
    GHI: null     // T (null) because key is same as parent key
  }
};
Enter fullscreen mode Exit fullscreen mode
Explanation
  • For the key ABC in shippingCosts:
    • ABC: null matches the outer key, so it gets the type T (null).
    • DEF: 12 and GHI: 13 do not match the outer key, so they get the type OtherType (number).
    • This pattern repeats for the keys DEF and GHI, ensuring that the cost from a store to itself is always null, while the cost to other stores is always a number.
Summary

The IfSameKeyThenTypeOtherwiseOther type ensures consistency in the shape of an object where:

  • If a key matches its own name, it gets a specific type T.
  • If a key does not match its own name, it gets another type OtherType.

This is particularly useful for scenarios like our shipping costs example, where certain keys require specific types, ensuring type safety and consistency across the object.


Creating Mapped Types with Generics

Mapped types allow you to create new types based on existing ones. For instance, you can create a type that transforms all properties of a given type to booleans:

type BooleanFields<T> = {
  [K in keyof T]: boolean;
};

type User = {
  email: string;
  name: string;
};

type UserFetchOptions = BooleanFields<User>;
Enter fullscreen mode Exit fullscreen mode

This results in:

type UserFetchOptions = {   
  email: boolean;   
  name: boolean; 
};
Enter fullscreen mode Exit fullscreen mode

Creating Conditional Types with Generics

Conditional types are generic types that resolve differently based on a condition:

type IsStringType<T> = T extends string ? true : false;
Enter fullscreen mode Exit fullscreen mode

This type checks if T extends string and returns true if it does, otherwise false.

Top comments (0)