Having Fun with TypeScript: Generics

Creating New Types out of Existing Types

Dagang Wei
3 min readMar 29, 2024

This blog post is part of the series Having Fun with TypeScript.

Introduction

Generics are a cornerstone of TypeScript’s robust type system. They offer a mechanism to create reusable components that seamlessly adapt to various data types. Imagine having a single function that gracefully handles sorting arrays of numbers, strings, or even custom objects — that’s the flexibility generics provide. Let’s dive into the world of TypeScript generics and see how they can elevate your code.

Understanding the Fundamentals

At their core, generics allow you to introduce type parameters, much like function parameters. These type parameters are often named ‘T’ by convention. They serve as placeholders for the actual types that will be supplied when you utilize a generic component. Here’s a simple illustration of a generic identity function:

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

let output1 = identity<string>("Hello, Generics!"); // output1 is of type string
let output2 = identity<number>(42); // output2 is of type number

The identity function takes an argument (arg) of type T and returns a value of the same type T. When you call it, you specify the concrete type within the angle brackets (<string>, <number>).

Benefits of Generics

  • Type Safety: Generics ensure that the types you work with are consistent throughout your code. This significantly reduces the likelihood of type-related errors.
  • Reusability: A single generic function or class can serve multiple purposes, eliminating redundant code for different data types.
  • Improved Readability: Using generics can make your code more self-documenting. Instead of deciphering specific types, the type variables convey the intended flexibility.

Let’s explore some common use cases for generics:

Generic Functions

function reverseArray<T>(arr: T[]): T[] {
return arr.reverse();
}

let numbers = [1, 2, 3];
let reversedNumbers = reverseArray<number>(numbers);

let names = ["Alice", "Bob", "Charlie"];
let reversedNames = reverseArray<string>(names);

Generic Interfaces

interface KeyValuePair<K, V> {
key: K;
value: V;
}

let numPair: KeyValuePair<number, number> = { key: 1, value: 10 };
let stringPair: KeyValuePair<string, boolean> = { key: "active", value: true};

Generic Classes

class GenericQueue<T> {
private data: T[] = [];
enqueue(item: T) { this.data.push(item); }
dequeue(): T | undefined { return this.data.shift(); }
}

let stringQueue = new GenericQueue<string>();
stringQueue.enqueue("First");
stringQueue.enqueue("Second");
console.log(stringQueue.dequeue());
console.log(stringQueue.dequeue());
console.log(stringQueue.dequeue());

Advanced Generics Techniques

Type Constraints

Sometimes, you need to ensure that the types used with your generics have certain properties or methods. Type constraints help you achieve that.

function getProperty<T extends { name: string }, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

const person = { name: "Alice", age: 30 };
let personName = getProperty(person, "name"); // Works
let personId = getProperty(person, "id"); // Error: Argument of type '"id"' is not assignable to parameter of type '"name" | "age"'.

Conditional Types

Conditional types provide dynamic type transformations based on conditions:

type NonNullable<T> = T extends null | undefined ? never : T;

type T1 = NonNullable<string>; // string
type T2 = NonNullable<null>; // never

Mapped Types

Mapped types let you create new types by transforming properties in existing types:

interface Person {
name: string;
age: number;
}

type ReadonlyPerson<T> = {
readonly [P in keyof T]: T[P];
}

let readonlyPerson: ReadonlyPerson<Person> = { name: "Bob", age: 25 };
readonlyPerson.name = "Charlie"; // Error: Cannot assign to readonly property

Inferring Types

TypeScript can often infer types within generics, reducing verbosity:

function createTuple<T, U>(first: T, second: U): [T, U] {
return [first, second];
}

const stringNumTuple = createTuple("Hello", 42); // Type is inferred as [string, number]

Example: A Generic Data Fetcher

Let’s illustrate a more complex scenario using these concepts:

interface Resource<T> {
id: number;
data: T;
}

class DataFetcher<T> {
constructor(private url: string) {}

async fetchData(): Promise<Resource<T>> {
const response = await fetch(this.url);
const data: T = await response.json();
return { id: 1, data }; // Placeholder, actual ID from response
}
}

const userFetcher = new DataFetcher< { name: string, role: string }>("https://api.users/1");
userFetcher.fetchData().then(user => console.log(user.data.name));

Advanced generic techniques in TypeScript open up a world of possibilities for crafting highly flexible, type-safe, and maintainable code structures.

Conclusion

Generics are an indispensable tool in any TypeScript developer’s arsenal. By mastering generics, you’ll write code that is more type-safe, reusable, and ultimately, easier to maintain.

--

--