Munsif.
AboutExperienceProjectsAchievementsBlogsContact
HomeAboutExperienceProjectsAchievementsBlogsContact
Munsif.

Frontend Developer crafting scalable web applications with modern technologies and clean code practices.

Quick Links

  • About
  • Experience
  • Projects
  • Achievements

Connect

© 2026 Shaik Munsif. All rights reserved.

Built with Next.js & Tailwind

0%
Welcome back!Continue where you left off
Back to Blogs
TypeScript

TypeScript Generics Mastery: The Complete Guide

Master TypeScript generics from the ground up. Covers generic functions, interfaces, classes, constraints, keyof, built-in utility types, conditional types, the infer keyword, mapped types, and 4 real-world architecture patterns with full interview preparation.

Feb 22, 202628 min read
TypeScriptGenericsType SystemInterview PrepBest Practices

Introduction

You've seen them everywhere: Array<string>, Promise<User>, Observable<Event>. Those angle brackets are TypeScript generics — and they're the single most powerful feature in the type system.

Generics allow you to write code that works with any type while still being completely type-safe. Without generics you face a painful choice: lose type safety (use any) or duplicate code for every type you want to support. Generics give you a third path — write once, type safely, use everywhere.

This guide is your complete, intensive reference to TypeScript generics. We'll cover the fundamentals, progressively move through constraints, multiple parameters, conditional types, the infer keyword, real-world patterns, and close with interview preparation. By the end, angle brackets will feel like second nature.


Why Generics Exist

Consider a function that returns the first element of an array:

typescript
// ❌ Without generics — loses type information
function first(arr: any[]): any {
    return arr[0];
}

const name = first(['Alice', 'Bob']); // type: any — could be anything!
name.toUpperCase(); // No error, but breaks at runtime if arr is empty
typescript
// ❌ Without generics — must duplicate for every type
function firstString(arr: string[]): string { return arr[0]; }
function firstNumber(arr: number[]): number { return arr[0]; }
function firstUser(arr: User[]): User { return arr[0]; }
// ... forever
typescript
// ✅ With generics — one function, full type safety
function first<T>(arr: T[]): T {
    return arr[0];
}

const name = first(['Alice', 'Bob']);   // type: string ✅
const age  = first([25, 30, 35]);       // type: number ✅
const user = first([{ id: 1 }]);        // type: { id: number } ✅

The T is a type variable — a placeholder filled in at call time. TypeScript infers it automatically from the argument you pass.


Generic Syntax Fundamentals

Declaring a Type Variable

typescript
// Single type variable
function identity<T>(value: T): T {
    return value;
}

// Multiple type variables (any letter works, but T, U, K, V are conventional)
function pair<T, U>(first: T, second: U): [T, U] {
    return [first, second];
}

Calling Generic Functions

TypeScript infers the type from the argument (preferred):

typescript
const result = identity('hello');      // T inferred as: string
const num    = identity(42);           // T inferred as: number

Or you can explicitly pass the type:

typescript
const result = identity<string>('hello'); // Explicit — useful when inference fails

Naming Conventions

LetterConventional Meaning
TType (general purpose)
U, VSecond/third type when T is taken
KKey type (especially keyof)
VValue type
EElement type (arrays)
RReturn type

Generic Functions

Basic Patterns

typescript
// Swap two values
function swap<T, U>(a: T, b: U): [U, T] {
    return [b, a];
}

const [y, x] = swap(1, 'hello'); // [string, number]

// Wrap a value
function wrap<T>(value: T): { value: T } {
    return { value };
}

const wrapped = wrap(42); // { value: number }

Generic Arrow Functions

typescript
// In .ts files
const identity = <T>(value: T): T => value;

// In .tsx files (JSX), add a trailing comma to avoid ambiguity with JSX tags
const identity = <T,>(value: T): T => value;

Real-World Example: Type-Safe API Fetcher

typescript
async function fetchData<T>(url: string): Promise<T> {
    const response = await fetch(url);
    if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json() as Promise<T>;
}

// Usage — TypeScript knows exactly what you're getting back
interface User {
    id: number;
    name: string;
    email: string;
}

const user = await fetchData<User>('/api/users/1');
console.log(user.name);  // ✅ TypeScript knows this is a string
console.log(user.xyz);   // ❌ Error: Property 'xyz' does not exist on User

Generic Interfaces

Interfaces can be generic too, capturing relationships between types:

typescript
interface Repository<T> {
    findById(id: number): Promise<T>;
    findAll(): Promise<T[]>;
    save(entity: T): Promise<T>;
    delete(id: number): Promise<void>;
}

// Implement for any entity type
class UserRepository implements Repository<User> {
    async findById(id: number): Promise<User> {
        return fetchData<User>(`/api/users/${id}`);
    }
    async findAll(): Promise<User[]> {
        return fetchData<User[]>('/api/users');
    }
    async save(user: User): Promise<User> {
        // POST/PUT logic
        return user;
    }
    async delete(id: number): Promise<void> {
        await fetch(`/api/users/${id}`, { method: 'DELETE' });
    }
}

Generic Response Wrapper

A common real-world pattern:

typescript
interface ApiResponse<T> {
    data: T;
    status: number;
    message: string;
    timestamp: Date;
}

// Functions that return consistent shapes
async function getUser(id: number): Promise<ApiResponse<User>> {
    // ...
}

async function getProducts(): Promise<ApiResponse<Product[]>> {
    // ...
}

Generic Classes

Classes can be parameterized to work with any type:

typescript
class Stack<T> {
    private items: T[] = [];

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

    pop(): T | undefined {
        return this.items.pop();
    }

    peek(): T | undefined {
        return this.items[this.items.length - 1];
    }

    isEmpty(): boolean {
        return this.items.length === 0;
    }

    get size(): number {
        return this.items.length;
    }
}

const numStack = new Stack<number>();
numStack.push(1);
numStack.push(2);
numStack.push('hello'); // ❌ Error: Argument of type 'string' not assignable to 'number'

const strStack = new Stack<string>();
strStack.push('hello');
strStack.push('world');
const top = strStack.peek(); // type: string | undefined ✅

Generic Queue

typescript
class Queue<T> {
    private items: T[] = [];

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

    dequeue(): T | undefined {
        return this.items.shift();
    }

    contains(item: T): boolean {
        return this.items.includes(item);
    }
}

const taskQueue = new Queue<() => void>();
taskQueue.enqueue(() => console.log('Task 1'));
taskQueue.enqueue(() => console.log('Task 2'));
const task = taskQueue.dequeue();
task?.(); // "Task 1"

Generic Constraints

Sometimes T can be too broad. Constraints let you require that T has certain properties.

The extends Keyword

typescript
// T must have a .length property
function logLength<T extends { length: number }>(value: T): T {
    console.log(`Length: ${value.length}`);
    return value;
}

logLength('hello');         // ✅ string has .length
logLength([1, 2, 3]);       // ✅ array has .length
logLength({ length: 10 });  // ✅ object with .length
logLength(42);              // ❌ Error: number has no .length

Constraining to an Interface

typescript
interface Identifiable {
    id: number;
}

function findById<T extends Identifiable>(items: T[], id: number): T | undefined {
    return items.find(item => item.id === id);
}

const users: User[] = [{ id: 1, name: 'Alice', email: 'a@a.com' }];
const user = findById(users, 1); // type: User | undefined ✅

// Works for ANY type that has an `id` field:
const products: Product[] = [...];
const product = findById(products, 5); // type: Product | undefined ✅

keyof Constraint — Safe Property Access

typescript
// K must be a key that actually exists in T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

const user = { id: 1, name: 'Alice', age: 25 };

const name = getProperty(user, 'name');  // type: string ✅
const age  = getProperty(user, 'age');   // type: number ✅
const xyz  = getProperty(user, 'xyz');   // ❌ Error: 'xyz' not in type ✅

This is how you write type-safe property accessors — no more obj[key as any]!

Multiple Constraints

typescript
interface Serializable {
    serialize(): string;
}

interface Comparable {
    compareTo(other: this): number;
}

// T must satisfy BOTH interfaces
function processItem<T extends Serializable & Comparable>(item: T): string {
    return item.serialize();
}

Default Type Parameters

Like default function parameters, but for types:

typescript
interface PaginatedResult<T, E = Error> {
    data: T[];
    total: number;
    error?: E;
}

// Without specifying error type — defaults to Error
const result: PaginatedResult<User> = {
    data: [],
    total: 0
};

// With a custom error type
const result2: PaginatedResult<User, string> = {
    data: [],
    total: 0,
    error: 'Not found'
};

Built-in Utility Types (Generics in Action)

TypeScript ships with powerful utility types, all implemented using generics. Understanding how they work makes you a better TypeScript developer.

Partial and Required

typescript
interface User {
    id: number;
    name: string;
    email: string;
    age: number;
}

// All fields optional — great for PATCH requests
type PartialUser = Partial<User>;
// { id?: number; name?: string; email?: string; age?: number }

// All fields required (even if originally optional)
type RequiredUser = Required<User>;

// Practical use: update function that accepts partial data
function updateUser(id: number, updates: Partial<User>): User {
    // Merge with existing user and return
}

updateUser(1, { name: 'Bob' });        // ✅ Only name
updateUser(1, { email: 'b@b.com' });   // ✅ Only email

Pick and Omit

typescript
// Select only specific fields
type UserPreview = Pick<User, 'id' | 'name'>;
// { id: number; name: string }

// Remove specific fields
type UserWithoutId = Omit<User, 'id'>;
// { name: string; email: string; age: number }

// Practical use: form data without the server-generated id
type CreateUserForm = Omit<User, 'id'>;

function createUser(data: CreateUserForm): Promise<User> {
    // POST to API, server generates id
}

Record

typescript
// Maps keys of type K to values of type V
type UserMap = Record<number, User>;
// { [key: number]: User }

const usersById: Record<number, User> = {
    1: { id: 1, name: 'Alice', email: 'a@a.com', age: 25 },
    2: { id: 2, name: 'Bob',   email: 'b@b.com', age: 30 },
};

// Maps string status to a color string
type StatusColor = Record<'active' | 'inactive' | 'pending', string>;
const statusColors: StatusColor = {
    active: 'green',
    inactive: 'gray',
    pending: 'yellow'
};

ReturnType and Parameters

typescript
function fetchUser(id: number, options: { cache: boolean }): Promise<User> {
    // ...
}

// Extract the return type
type FetchUserReturn = ReturnType<typeof fetchUser>; // Promise<User>

// Extract the parameters as a tuple
type FetchUserParams = Parameters<typeof fetchUser>; // [number, { cache: boolean }]

// Super useful for mocking, wrapping, and decorating functions
function wrapWithLogging<T extends (...args: any[]) => any>(fn: T) {
    return (...args: Parameters<T>): ReturnType<T> => {
        console.log('Calling with:', args);
        const result = fn(...args);
        console.log('Result:', result);
        return result;
    };
}

Readonly

typescript
// Makes all properties immutable
type ImmutableUser = Readonly<User>;

const user: ImmutableUser = { id: 1, name: 'Alice', email: 'a@a.com', age: 25 };
user.name = 'Bob'; // ❌ Error: Cannot assign to 'name' — it is read-only

Conditional Types

Conditional types let you choose between types based on a condition — like a ternary operator for types.

typescript
// Syntax: T extends U ? TypeIfTrue : TypeIfFalse
type IsString<T> = T extends string ? true : false;

type A = IsString<string>;  // true
type B = IsString<number>;  // false
type C = IsString<'hello'>; // true (string literal extends string)

Distributive Conditional Types

When T is a union, conditional types distribute over each member:

typescript
type ToArray<T> = T extends any ? T[] : never;

type StringOrNumberArray = ToArray<string | number>;
// string[] | number[]  (not (string | number)[])

NonNullable — Removing null and undefined

typescript
// Built-in utility using conditional types
type NonNullable<T> = T extends null | undefined ? never : T;

type MaybeString = string | null | undefined;
type DefinitelyString = NonNullable<MaybeString>; // string

Practical Conditional Type — FlattenArray

typescript
type FlattenArray<T> = T extends Array<infer Item> ? Item : T;

type StringItem   = FlattenArray<string[]>;   // string
type NumberItem   = FlattenArray<number[]>;   // number
type NotAnArray   = FlattenArray<boolean>;    // boolean (not an array, stays as-is)

The infer Keyword

infer lets you extract a type from another type within a conditional type. It's TypeScript saying "figure out this type for me."

Extracting the Return Type

typescript
// This is exactly how ReturnType<T> works internally
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function greet(name: string): string {
    return `Hello, ${name}!`;
}

type GreetReturn = MyReturnType<typeof greet>; // string

Extracting the Element Type of an Array

typescript
type ElementType<T> = T extends (infer E)[] ? E : never;

type StrElement  = ElementType<string[]>;   // string
type NumElement  = ElementType<number[][]>; // number[] (one level deep)

Extracting Promise Value

typescript
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;

type UserFromPromise = UnwrapPromise<Promise<User>>; // User
type PlainNumber     = UnwrapPromise<number>;         // number (not a Promise)

Extracting First Parameter

typescript
type FirstParam<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;

function createUser(name: string, age: number): User { /* ... */ }

type FirstArg = FirstParam<typeof createUser>; // string

Mapped Types with Generics

Mapped types transform the properties of an existing type:

typescript
// Make all properties optional (same as Partial<T>)
type Optional<T> = {
    [K in keyof T]?: T[K];
};

// Make all properties nullable
type Nullable<T> = {
    [K in keyof T]: T[K] | null;
};

// Add a validation flag to every field
type WithValidation<T> = {
    [K in keyof T]: {
        value: T[K];
        isValid: boolean;
        error?: string;
    };
};

type UserForm = WithValidation<User>;
// {
//   id:    { value: number;  isValid: boolean; error?: string }
//   name:  { value: string;  isValid: boolean; error?: string }
//   email: { value: string;  isValid: boolean; error?: string }
//   ...
// }

Real-World Patterns

Pattern 1: Type-Safe Event Emitter

typescript
type EventMap = {
    userLogin:  { userId: string; timestamp: Date };
    userLogout: { userId: string };
    pageView:   { path: string; referrer?: string };
};

class TypedEventEmitter<Events extends Record<string, unknown>> {
    private listeners = new Map<keyof Events, Function[]>();

    on<K extends keyof Events>(event: K, listener: (data: Events[K]) => void): void {
        const existing = this.listeners.get(event) || [];
        this.listeners.set(event, [...existing, listener]);
    }

    emit<K extends keyof Events>(event: K, data: Events[K]): void {
        const handlers = this.listeners.get(event) || [];
        handlers.forEach(handler => handler(data));
    }
}

const emitter = new TypedEventEmitter<EventMap>();

emitter.on('userLogin', ({ userId, timestamp }) => {
    console.log(`User ${userId} logged in at ${timestamp}`);
});

emitter.emit('userLogin', { userId: '123', timestamp: new Date() }); // ✅
emitter.emit('userLogin', { userId: '123' }); // ❌ Error: missing timestamp
emitter.emit('unknown', {});                  // ❌ Error: 'unknown' not in EventMap

Pattern 2: Generic Form Builder

typescript
type FormSchema<T> = {
    [K in keyof T]: {
        label: string;
        type: 'text' | 'number' | 'email' | 'select';
        required?: boolean;
        options?: string[]; // for 'select' type
    };
};

function buildForm<T>(schema: FormSchema<T>, initialValues: Partial<T>) {
    // Render form dynamically based on schema
    // TypeScript ensures schema keys match T's keys
}

buildForm<CreateUserForm>(
    {
        name:  { label: 'Full Name', type: 'text', required: true },
        email: { label: 'Email',     type: 'email', required: true },
        age:   { label: 'Age',       type: 'number' },
    },
    { name: 'Alice' }
);

Pattern 3: Type-Safe State Management

typescript
interface Action<T extends string, P = void> {
    type: T;
    payload: P;
}

type UserActions =
    | Action<'SET_USER', User>
    | Action<'CLEAR_USER'>
    | Action<'UPDATE_EMAIL', { email: string }>;

function userReducer(state: User | null, action: UserActions): User | null {
    switch (action.type) {
        case 'SET_USER':
            return action.payload;   // TypeScript knows payload is User ✅
        case 'CLEAR_USER':
            return null;             // TypeScript knows payload is void ✅
        case 'UPDATE_EMAIL':
            return state ? { ...state, email: action.payload.email } : null; // ✅
    }
}

Pattern 4: Generic Memoization

typescript
function memoize<Args extends unknown[], R>(fn: (...args: Args) => R) {
    const cache = new Map<string, R>();

    return (...args: Args): R => {
        const key = JSON.stringify(args);
        if (cache.has(key)) {
            return cache.get(key)!;
        }
        const result = fn(...args);
        cache.set(key, result);
        return result;
    };
}

const expensiveCalc = memoize((n: number, factor: number): number => {
    // Expensive computation...
    return n * factor;
});

expensiveCalc(100, 2); // Computes
expensiveCalc(100, 2); // Returns from cache
expensiveCalc('a', 2); // ❌ Error: 'a' is not assignable to number

Common Mistakes to Avoid

Mistake 1: Using any Instead of a Type Variable

typescript
// ❌ WRONG — defeats type safety
function firstAny(arr: any[]): any {
    return arr[0];
}
const name = firstAny(['Alice']); // type: any — no safety

// ✅ CORRECT — preserves the type
function first<T>(arr: T[]): T | undefined {
    return arr[0];
}
const name = first(['Alice']); // type: string

Mistake 2: Constraining Too Broadly

typescript
// ❌ TOO BROAD — `object` excludes primitives but loses specific shape info
function merge<T extends object>(a: T, b: T): T {
    return { ...a, ...b } as T; // May lose excess properties
}

// ✅ BETTER — use record or intersection
function merge<T extends Record<string, unknown>>(a: T, b: Partial<T>): T {
    return { ...a, ...b } as T;
}

Mistake 3: Forgetting extends Causes Distribution

typescript
type IsArray<T> = T extends any[] ? true : false;

// When T is a union, the conditional distributes over each member:
type Result = IsArray<string | number[]>;
// → IsArray<string> | IsArray<number[]>
// → false | true
// → boolean  (not simply true or false!)

// To prevent distribution, wrap in a tuple:
type IsArrayStrict<T> = [T] extends [any[]] ? true : false;
type Result2 = IsArrayStrict<string | number[]>; // false

Mistake 4: Not Narrowing After infer

typescript
type GetFirst<T> = T extends [infer F, ...any[]] ? F : never;

type First = GetFirst<[string, number, boolean]>; // string ✅
type Empty = GetFirst<[]>;                         // never ✅

Complexity Summary

ConceptDifficultyUse Case
Generic functionBeginnerReusable utilities
Generic interface/classBeginnerData containers, repos
Constraints (extends)IntermediateSafe property access
keyof + genericsIntermediateDynamic property access
Utility typesIntermediateTransforming existing types
Conditional typesAdvancedType-level logic
inferAdvancedExtracting nested types
Mapped typesAdvancedType transformations

Interview Preparation

Common Interview Questions

Q: What's the difference between any and generics?

any disables type checking entirely — you lose all IntelliSense and error detection. Generics preserve type relationships: TypeScript still knows the concrete type and checks it at every usage site. Use generics when you want flexibility and safety; use any almost never.

Q: When would you use a generic constraint (extends)?

When your function or class needs to call methods or access properties on T, but T could be anything. Without a constraint, TypeScript won't let you access .id on an unknown T. Adding T extends { id: number } tells TypeScript the minimum shape T must satisfy, enabling safe access while keeping the function flexible.

Q: Explain keyof in combination with generics.

keyof T produces a union of all property names of T. Combined with generics: function get<T, K extends keyof T>(obj: T, key: K): T[K] creates a type-safe property accessor. The return type T[K] (indexed access type) is exactly the type of property K on T — eliminating the need for type assertions.

Q: What is a conditional type? Give a real-world example.

A conditional type uses the T extends U ? X : Y syntax to choose between types based on a relationship. Real-world example: NonNullable<T> = T extends null | undefined ? never : T removes null and undefined from a type. They're also used for extracting types via infer — e.g., ReturnType<T> extracts what a function returns.

Q: What does infer do and when is it useful?

infer introduces a new type variable inside a conditional type that TypeScript fills in by pattern-matching. It's used to extract types from other types: from a function's return type (infer R), from an array's element type (infer E), or from a Promise's resolved value (infer V). Without infer, extracting these types would require manual type assertions.

Q: What are mapped types and how do they relate to generics?

Mapped types create new types by iterating over the keys of a type ([K in keyof T]) and transforming each. They're always generic since they take T as an input. The entire Partial<T>, Required<T>, Readonly<T>, Record<K, V> family is built on mapped types. You can add/remove the ? (optional) and readonly modifiers as part of the mapping.


Conclusion

TypeScript generics transform the language from a simple type annotator into a full type-level programming system. The journey has four stages:

  1. Generic functions and interfaces — write once, use with any type
  2. Constraints — keep flexibility while requiring a minimum contract
  3. Utility types — transform existing types without duplication
  4. Conditional types and infer — build type-level logic and extract hidden types

The key insight: generics are just variables for types. Once you internalize that, every new generic pattern becomes intuitive — you're just declaring, constraining, and composing type variables the same way you work with value variables.

💡 tip

[!TIP] The best way to get comfortable with generics is to look at TypeScript's own source for utility types (in lib.es5.d.ts). Every Partial, Record, ReturnType is implemented in plain TypeScript — reading them is the fastest way to level up.

🧠 Test Your Knowledge

Now that you've learned the concepts, let's see if you can apply them! Take this quick quiz to test your understanding.

PreviousTwo Pointers Algorithm Mastery: The Complete Intensive GuideNextAngular Testing Mastery: The Complete Guide to TestBed, Mocking & Beyond

Written by

Shaik Munsif

Read more articles

Found this helpful? Share it with your network!

On this page

0/50
Question 1 of 10Easy
Score: 0/0

What does T represent in function identity<T>(value: T): T?