Having Fun with TypeScript: Generics
Creating New Types out of Existing Types
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.