Mastering TypeScript Generics: A Simple Guide
Tags: typescript, programming, frontend, webdev
Published: 2024-05-28 14:15:39

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:

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

const result = identity<string>("Hello, TypeScript!");
```

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:

```typescript
type ProgrammingLanguage = {
  name: string;
};

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

const result = identity<ProgrammingLanguage>({ name: "TypeScript" });
```

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:

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

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

```typescript

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');
```

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:

```typescript
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);
```

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:

```typescript
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 })
}
```

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:

```typescript
function stringifyObjectKeyValues<T extends Record<string, any>>(obj: T) {
  // ...
}
```
`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:

```typescript
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;
}*/
```

## 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:

```typescript
interface MyInterface<T> {
  field: T
}
```

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


##### Classes:

```typescript
class MyClass<T> {
  field: T
  constructor(field: T) {
    this.field = field
  }
}
```

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:


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

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

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.

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

app.get('/api', async () => {
  console.log(context.someValue)
});
```

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:

```typescript
type Partial<T> = {   
 [P in keyof T]?: T[P]; 
};
```

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:

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

```

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.

```typescript
type IfSameKeyThenTypeOtherwiseOther<Keys extends string, T, OtherType> = {
  [K in Keys]: {
    [SameKey in K]: T;
  } & {
    [OtherKey in Exclude<Keys, K>]: OtherType;
  };
};
```

##### 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.

2. Mapped Type:

```typescript
[K in Keys]:
```

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

3. Inner Object Type:

The inner object type is divided into two parts:

```typescript
{
 [SameKey in K]: T;
}
```


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`.


```typescript
{
 [OtherKey in Exclude<Keys, K>]: OtherType;
}
```


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

4. Combining with Intersection (`&`):


```typescript
{
  [K in Keys]: {
    [SameKey in K]: T;
  } & {
    [OtherKey in Exclude<Keys, K>]: OtherType;
  };
}
```


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

##### Example

```typescript
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
  }
};
```

##### 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:

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

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

type UserFetchOptions = BooleanFields<User>;
```


This results in:

```typecript
type UserFetchOptions = {   
  email: boolean;   
  name: boolean; 
};
```

## Creating Conditional Types with Generics

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

```typescript
type IsStringType<T> = T extends string ? true : false;
```

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