🚀 Mastering Generics in TypeScript: A Fun & Simple Guide 🎯

🚀 Mastering Generics in TypeScript: A Fun & Simple Guide 🎯

Have you ever looked at TypeScript's generics and thought, "Nope, not today!"? Yeah, me too. For the longest time, I avoided them like they were some kind of dark magic. But guess what? They’re not scary at all! Once I actually took the time to understand them, I realized they’re one of the coolest features TypeScript has to offer. So, if you've ever been intimidated by generics, don’t worry—you’re not alone. Let’s break them down together!

🎯 What Are Generics, Anyway?

Think of generics as reusable templates for your TypeScript code. Instead of writing separate functions, classes, or types for different data types, you can use generics to handle them all. Imagine you’re designing a box 📦 that can store anything—apples 🍏, books 📚, or even numbers 🔢. You wouldn’t want to create a separate box type for each item, right? Generics let you define a single box type that works for all!

Why Should You Care?

Before we jump into examples, let’s talk about why generics matter. TypeScript is all about adding type safety while keeping the flexibility of JavaScript. Generics help us achieve this by:

  1. Reusing Code – Instead of writing the same logic for different data types, you write it once and reuse it.

  2. Keeping Type Safety – No more accidental type mismatches that cause bugs.

  3. Improving Code Readability – Generic functions and classes clearly express that they work with multiple data types.

If you’ve ever found yourself copying and pasting similar code with different types, then generics can make your life much easier.

Simple Example:

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

console.log(identity<number>(42)); // 42
console.log(identity<string>('Hello, TypeScript!')); // Hello, TypeScript!

See that <T> thing? That’s a generic type parameter. It’s like telling TypeScript, “Hey, this function works with any type, just let me know what it is when I use it.” Pretty handy, right?

🔧 Built-in Generics

TypeScript comes with some built-in generics to make our lives easier. These provide flexible, type-safe utilities that we can use without reinventing the wheel. Let’s check out a few:

1️⃣ Arrays

// Example 1
const friends: Array<string> = ['Alice', 'Bob', 'Charlie'];

// Example 2
const numbers: Array<number> = [10, 20, 30];

Here, Array<T> ensures friends contains only strings, and numbers contains only numbers. Simple and effective!

2️⃣ Promises

// Example 1
const fetchData: Promise<string> = new Promise((resolve) => {
  resolve('Data fetched successfully!');
});

// Example 2
const fetchData2: Promise<number> = new Promise((resolve, reject) => {
  resolve(100);
});

This guarantees that fetchData will always return a string. No more unexpected undefined values messing up your code!

3️⃣ Sets

A Set<T> stores unique values, meaning no duplicates are allowed.

// Example 1
const uniqueNumbers: Set<number> = new Set([1, 2, 3, 3, 4]);
console.log(uniqueNumbers); // Set { 1, 2, 3, 4 }

// Example 2
const uniqueStrings: Set<string> = new Set(['apple', 'banana', 'cherry']);
console.log(uniqueStrings); // Set { 'apple', 'banana', 'cherry' }

4️⃣ Maps

A Map<K, V> stores key-value pairs, where K is the key type and V is the value type.

const userRoles: Map<number, string> = new Map([
  [1, 'Admin'],
  [2, 'User'],
  [3, 'Guest']
]);


console.log(userRoles.keys()); // MapIterator { 1, 2, 3 }
console.log([...userRoles.values()]); // [ 'Admin', 'User', 'Guest' ]
console.log(userRoles.entries()); // MapIterator { [ 1, 'Admin' ], [ 2, 'User' ], [ 3, 'Guest' ] }

5️⃣ ReadonlyArray

A ReadonlyArray<T> ensures that the array cannot be modified after creation.

// Example 1
const readOnlyNumbers: ReadonlyArray<number> = [1, 2, 3, 4, 5];

// Example 2
const readOnlyStrings: ReadonlyArray<string> = ['apple', 'banana', 'cherry'];

Trying to modify this array will result in a TypeScript error.

readOnlyNumbers.push(6); // Error: Property 'push' does not exist on type 'readonly number[]'.
readOnlyStrings.push('date'); // Error: Property 'push' does not exist on type 'readonly string[]'.

6️⃣ Record

A Record<K, V> is a utility type that helps define an object with specific key and value types.

// Example
const numberRecord: Record<number, string> = {
  1: 'one',
  2: 'two',
  3: 'three',
};

const friendsRecord: Record<string, number> = {
  Alice: 25,
  Bob: 30,
  Charlie: 35,
};

🔔 Note: The Record utility type only works with objects. You can’t use it for other structures like arrays or functions. It’s strictly for defining objects where the keys and values are strongly typed.

🛠️ Custom Generics

While TypeScript’s built-in generics are powerful, sometimes we need more flexibility. That’s where custom generics come in! They allow us to create reusable, type-safe structures tailored to our specific needs.

1️⃣ Generic Type Alias

A generic type alias is a reusable type definition that works with different data types.

// Example 1
type Point<T> = {
  x: T;
  y: T;
};

const point1: Point<number> = { x: 10, y: 20 };

// Example 2
type Pair<T, U> = {
  first: T;
  second: U;
};

const pair1: Pair<number, string> = { first: 1, second: 'two' };

2️⃣ Generic Interface

A generic interface allows for defining flexible, reusable types with generic parameters.

// Example 1
interface Value<T> {
  value: T; // same type as interface type.
}

const numberValue: Value<number> = { value: 100 }; // number
const stringValue: Value<string> = { value: 'Hello, TypeScript!' }; // string

// Example 2
interface PairValue<T, U> {
  first: T;
  second: U;
}

const numberPair: PairValue<number, number> = { first: 1, second: 2 }; // number, number
const stringPair: PairValue<string, string> = { first: 'one', second: 'two' }; // string, string
const stringNumberPair: PairValue<string, number> = { first: 'one', second: 2 }; // string, number

3️⃣ Generic Class

A generic class is a blueprint for objects that can work with different data types.

// Example
class Cart<T> {
  private items: Array<T> = [];

  addItem(item: T): void {
    this.items.push(item);
  }

  getItems(): Array<T> {
    return [...this.items];
  }
}

// Instance
const cart1 = new Cart<string>();
cart1.addItem('apple');
cart1.addItem('banana');
cart1.addItem('cherry');
console.log(cart1.getItems()); // ['apple', 'banana', 'cherry']

🚀 Utility Types

TypeScript provides utility types that help manipulate types in different ways.

1️⃣ Partial

The Partial<T> utility makes all properties of a type optional.

// Example 1
interface User {
  name: string;
  age: number;
}

const updateUser: Partial<User> = { name: 'Alice' };

// Example 2
interface Family {
  name: string;
  heritage: string;
  title: string;
}

function createFamilyMember(member: Partial<Family>): Family {
  return {
    name: member.name || 'Unknown',
    heritage: member.heritage || 'Unknown',
    title: member.title || 'Unknown',
  };
  //   } as Family;
}

const member1 = createFamilyMember({ name: 'Alice' }); // { name: 'Alice', heritage: 'Unknown', title: 'Unknown' }
console.log(member1);

2️⃣ Required

The Required<T> utility makes all properties of a type mandatory.

// Example 1
interface Trainer {
  name?: string;
  age?: number;
}

const myTrainer: Required<Trainer> = { name: 'John', age: 30 };

// Example 2
interface Trainer {
  name: string;
  age: number;
  contact: number | string;
}

const myTrainer: Required<Trainer> = {
  name: 'Hassani',
  age: 25,
  contact: `+254796435237`,
};

console.log(myTrainer); // { name: 'Hassani', age: 25, contact: '+254796435237' }

⛓️ Generic Constraints: Giving Generics Some Boundaries

So far, we’ve seen how generics give us flexibility—they work with any type! But let’s be honest: too much flexibility can sometimes lead to chaos. 🤯

Imagine you're writing a function that works with anything… but then you accidentally pass in something unexpected, and BOOM—TypeScript starts throwing errors. That’s where generic constraints come to the rescue!

Constraints allow us to set some ground rules for our generics. Instead of saying, "Hey, use whatever type you want!", we can say, "You can use any type... as long as it meets certain conditions." Let’s explore how they work! 🚀

🏗 Function Constraints: Only Accepting Certain Types

Let’s say we want to write a function that logs the length of something. It should work for strings, arrays, and objects that have a length property—but we don’t want people passing in numbers or random objects without length.

Here’s how we constrain our generic to only accept things with a length property:

interface Lengthwise {
  length: number;
}

function logLength<T extends Lengthwise>(arg: T): void {
  console.log(arg.length);
}

logLength('Hello, TypeScript!'); // ✅ Works! Output: 18
logLength([1, 2, 3]); // ✅ Works! Output: 3
logLength({ length: 5 }); // ✅ Works! Output: 5
logLength(42); // ❌ Error! Numbers don’t have a length property.

Our generic <T> must extend Lengthwise, meaning TypeScript will only allow values that have a length property. This will be things like strings, arrays, and objects with length property. No more unexpected type errors!

🔑 Using keyof to Constrain Object Keys

Now, let’s say we have an object, and we want to get a property from it. But we don’t want someone passing in a random key that doesn’t exist!

Enter the keyof constraint. It ensures that the key we pass in is actually a valid key of the object:

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

const person = {
  name: 'Alice',
  age: 30,
};

console.log(getProperty(person, 'name')); // ✅ Alice
console.log(getProperty(person, 'age')); // ✅ 30
console.log(getProperty(person, 'gender')); // ❌ Error! 'gender' doesn't exist in 'person'.

Without keyof, TypeScript would let us pass any string, even if it wasn’t a real key in our object. This constraint protects us from mistakes and ensures we only access valid properties.

🏗 Class Constraints: Restricting Allowed Types

Let’s say we’re building a data storage class, but we only want to allow numbers, strings, or objects. We don’t want someone storing functions, symbols, or other weird stuff.

We can use constraints to restrict the allowed types:

class DataStorage<T extends number | string | object> {
  private data: T[] = [];

  addItem(item: T): void {
    this.data.push(item);
  }

  removeItem(item: T): void {
    const index = this.data.indexOf(item);
    if (index !== -1) {
      this.data.splice(index, 1);
    }
  }

  getItems(): T[] {
    return [...this.data];
  }
}

// Storing strings
const nameStorage = new DataStorage<string>();
nameStorage.addItem('Alice');
nameStorage.addItem('Bob');
nameStorage.removeItem('Alice');
console.log(nameStorage.getItems()); // ['Bob']

// Storing numbers
const numberStorage = new DataStorage<number>();
numberStorage.addItem(10);
numberStorage.addItem(20);
console.log(numberStorage.getItems()); // [10, 20]

// ❌ Error! We didn't allow functions.
const functionStorage = new DataStorage<() => void>();

🎯 What’s Happening Here?

  • The <T> generic must be a number, string, or object.

  • If someone tries to use a function or another unsupported type, TypeScript throws an error.

This way, we keep our data storage type-safe and easy to use!

🔗 Where to Learn More

🎉 Conclusion

Generics used to scare me, but now I love them. If you feel overwhelmed, just remember: every expert was once a beginner. Keep practicing, and generics will start to feel like second nature.

So, next time you see a <T> in TypeScript, don’t panic. Embrace it. Generics are your friends. 😉

Happy coding! 🚀