TypeScript Generics for Beginners: A Practical Guide With Code Examples

If you've been writing TypeScript for a while, you've probably run into a situation where you needed a function to work with different types — strings, numbers, objects — without losing type safety. That's exactly where TypeScript generics come in.

Generics are one of the most powerful features in TypeScript, yet they often intimidate beginners. The angle brackets, the mysterious T, the constraints — it can feel like a foreign language. But once you understand the core concept, generics become an indispensable tool in your developer toolkit.

In this guide, we'll break down TypeScript generics from scratch with real code examples, practical use cases, and common pitfalls to avoid.

What Are Generics?

Generics are type variables — placeholders for types that get filled in later when the code is actually used. They let you write functions, classes, and interfaces that work with any type while still maintaining full type safety.

Think of generics like function parameters, but for types. Just as a function parameter lets you pass different values, a generic type parameter lets you pass different types.

Here's the simplest possible example — the identity function:

// Without generics — loses type information
function identity(value: any): any {
  return value;
}

const result = identity("hello"); // result is 'any' — not helpful

// With generics — preserves type information
function identityGeneric<T>(value: T): T {
  return value;
}

const result2 = identityGeneric("hello"); // result2 is 'string'
const result3 = identityGeneric(42);      // result3 is 'number'

The <T> is the generic type parameter. When you call identityGeneric("hello"), TypeScript infers that T is string, so the return type is also string. You get full IntelliSense and compile-time checking without writing separate functions for each type.

Why Generics Matter

Without generics, you have two bad options:

  1. Use any — which disables type checking entirely and defeats the purpose of TypeScript.
  2. Duplicate code — write separate functions for string, number, boolean, and every other type you need.

Generics solve both problems by letting you write one implementation that works with any type while preserving type safety. They follow the DRY (Don't Repeat Yourself) principle and are essential for building reusable libraries, utility functions, and data structures.

Getting Started: Basic Syntax

Generic Functions

The most common use of generics is in functions. You declare a type parameter in angle brackets before the function's parentheses:

function getFirst<T>(arr: T[]): T | undefined {
  return arr[0];
}

const firstNumber = getFirst([10, 20, 30]);   // number | undefined
const firstString = getFirst(["a", "b", "c"]); // string | undefined

You can also use multiple type parameters when your function relates different types:

function pair<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}

const entry = pair("name", "Alice"); // [string, string]
const config = pair("port", 3000);   // [string, number]

Notice how TypeScript infers the types automatically from the arguments. You rarely need to specify them explicitly, though you can when needed:

const explicit = pair<string, boolean>("active", true);

Generic Interfaces

Interfaces can also use generics to define flexible data shapes:

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

// The same interface works for any data type
interface User {
  id: number;
  name: string;
  email: string;
}

interface Product {
  id: number;
  title: string;
  price: number;
}

const userResponse: ApiResponse<User> = {
  data: { id: 1, name: "Alice", email: "alice@example.com" },
  status: 200,
  message: "Success",
  timestamp: new Date(),
};

const productResponse: ApiResponse<Product> = {
  data: { id: 101, title: "Keyboard", price: 79.99 },
  status: 200,
  message: "Success",
  timestamp: new Date(),
};

This pattern is incredibly common in real-world applications. Instead of creating UserApiResponse, ProductApiResponse, and OrderApiResponse separately, you define the shape once and parameterize it.

Generic Classes

Classes can also be generic. A classic example is a type-safe collection:

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];
  }

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

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push("three"); // Error: Argument of type 'string' is not assignable

const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");
const top = stringStack.pop(); // type is string | undefined

The Stack<number> instance only accepts numbers, and the Stack<string> instance only accepts strings. TypeScript enforces this at compile time, catching bugs before they reach production.

Generic Type Aliases

You can also use generics with type aliases, which is useful for defining reusable utility types:

type Nullable<T> = T | null;
type ReadonlyArray<T> = readonly T[];

type Result<T, E = Error> =
  | { success: true; data: T }
  | { success: false; error: E };

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) {
    return { success: false, error: "Division by zero" };
  }
  return { success: true, data: a / b };
}

const result = divide(10, 2);
if (result.success) {
  console.log(result.data); // TypeScript knows this is a number
} else {
  console.log(result.error); // TypeScript knows this is a string
}

Note the E = Error syntax — that's a default type parameter. If you don't specify the error type, it defaults to Error.

Deep Dive: Advanced Generic Patterns

Generic Constraints

Sometimes you need to restrict what types a generic can accept. You do this with the extends keyword:

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(item: T): T {
  console.log(`Length: ${item.length}`);
  return item;
}

logLength("hello");       // Works — strings have .length
logLength([1, 2, 3]);     // Works — arrays have .length
logLength({ length: 10 }); // Works — object has .length
logLength(42);             // Error — numbers don't have .length

Constraints are essential when your generic function needs to access specific properties. Without the constraint, TypeScript wouldn't know that T has a length property.

A practical example with the keyof operator:

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

const user = { name: "Alice", age: 30, email: "alice@example.com" };

const name = getProperty(user, "name");  // type is string
const age = getProperty(user, "age");    // type is number
getProperty(user, "phone");              // Error: "phone" is not a key of user

This is one of the most useful patterns in TypeScript. The K extends keyof T constraint ensures you can only pass valid property names, and the return type T[K] automatically matches the property's type.

Conditional Types

Conditional types let you create types that depend on a condition, using the extends keyword in a ternary-like syntax:

type IsString<T> = T extends string ? "yes" : "no";

type A = IsString<string>;  // "yes"
type B = IsString<number>;  // "no"

// Practical example: extracting return types
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;

type Resolved = UnpackPromise<Promise<string>>; // string
type NotPromise = UnpackPromise<number>;         // number

The infer keyword is particularly powerful — it lets you extract types from within other types. TypeScript's built-in ReturnType<T> utility uses this exact pattern under the hood.

Mapped Types

Mapped types let you transform existing types by iterating over their properties:

// Make all properties optional
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// Make all properties readonly
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

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

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

type PartialUser = MyPartial<User>;
// { name?: string; age?: number; email?: string; }

type ReadonlyUser = MyReadonly<User>;
// { readonly name: string; readonly age: number; readonly email: string; }

Built-in Utility Types

TypeScript ships with several utility types built on generics that you should know:

interface Todo {
  title: string;
  description: string;
  completed: boolean;
  createdAt: Date;
}

// Partial<T> — makes all properties optional
function updateTodo(todo: Todo, updates: Partial<Todo>): Todo {
  return { ...todo, ...updates };
}

// Pick<T, K> — select specific properties
type TodoPreview = Pick<Todo, "title" | "completed">;

// Omit<T, K> — exclude specific properties
type TodoWithoutDates = Omit<Todo, "createdAt">;

// Record<K, V> — create an object type with specific key and value types
type StatusMap = Record<string, boolean>;

// Required<T> — makes all properties required
type StrictTodo = Required<Partial<Todo>>;

// Readonly<T> — makes all properties readonly
type FrozenTodo = Readonly<Todo>;

These utility types are used constantly in real-world TypeScript code. Learning them will save you from reinventing the wheel and make your code more idiomatic.

Common Mistakes to Avoid

1. Overusing any Instead of Generics

This is the most common mistake. When a function needs to accept different types, reach for generics instead of any:

// Bad — loses all type information
function merge(a: any, b: any): any {
  return { ...a, ...b };
}

// Good — preserves and relates types
function merge<T, U>(a: T, b: U): T & U {
  return { ...a, ...b };
}

const merged = merge({ name: "Alice" }, { age: 30 });
// merged is { name: string } & { age: number }
// Full IntelliSense for merged.name and merged.age

2. Too Many Type Parameters

Adding type parameters that don't relate to other parameters is pointless and adds noise:

// Bad — T is only used once and doesn't relate anything
function getLength<T>(arr: T[]): number {
  return arr.length;
}

// Good — T is unnecessary here, just use any[]
function getLength(arr: any[]): number {
  return arr.length;
}

// Even better — use unknown[] for type safety
function getLength(arr: unknown[]): number {
  return arr.length;
}

Remember the Golden Rule of Generics: type parameters are for relating the types of multiple values. If your type parameter only appears once in the function signature, you probably don't need it.

3. Not Using Constraints When Accessing Properties

// Bad — TypeScript error: Property 'id' does not exist on type 'T'
function getId<T>(item: T): number {
  return item.id; // Error!
}

// Good — constrain T to types that have an id property
function getId<T extends { id: number }>(item: T): number {
  return item.id; // Works!
}

4. Over-Specifying Types Instead of Letting TypeScript Infer

// Unnecessarily verbose
const result = identityGeneric<string>("hello");

// Let TypeScript infer — cleaner and just as type-safe
const result = identityGeneric("hello");

TypeScript's type inference is excellent. Only specify type arguments explicitly when TypeScript can't infer them correctly or when you want to be more restrictive than what would be inferred.

Frequently Asked Questions

Do generics affect runtime performance?

No. TypeScript generics are a compile-time feature only. All type information, including generics, is erased during compilation. The resulting JavaScript has zero overhead from generics. However, very complex generic types can slow down the TypeScript compiler itself during development.

When should I use generics vs. union types?

Use union types when you have a known, finite set of types: string | number | boolean. Use generics when you don't know the type ahead of time, or when you need to preserve and relate types between inputs and outputs. If you're writing a utility or library function that should work with any type the caller provides, generics are the right choice.

What's the difference between T and any?

any opts out of type checking entirely. Once a value is typed as any, TypeScript stops tracking its type. T (a generic) preserves the specific type throughout the function. If you pass a string to a generic function, TypeScript knows the return value is also a string. With any, that information is lost.

Can I use multiple generic type parameters?

Yes. Use multiple type parameters when you need to relate different types. Common conventions are T for the primary type, U or K for a secondary type, V for a value type, and E for an error type. For complex generics, consider using descriptive names like TInput, TOutput, or TResponse for better readability.

Are generics the same as in Java or C#?

Conceptually, yes — they solve the same problem of type-safe reusable code. However, TypeScript generics are erased at compile time (like Java's type erasure), whereas C# generics are reified (preserved at runtime). TypeScript's structural type system also makes generics more flexible than in nominally-typed languages.

Conclusion

TypeScript generics are not as scary as they first appear. At their core, they're simply type variables — placeholders that let you write flexible, reusable code without sacrificing type safety.

Start with the basics: generic functions that preserve input types. Then move to generic interfaces for consistent data shapes like API responses. When you're comfortable, explore constraints, conditional types, and mapped types for more advanced patterns.

The key principles to remember:

  • Generics relate types between inputs and outputs. If a type parameter only appears once, you probably don't need it.
  • Prefer generics over any whenever you need flexibility with type safety.
  • Use constraints when your generic code needs to access specific properties.
  • Leverage built-in utility types like Partial, Pick, Omit, and Record instead of building your own.
  • Let TypeScript infer types whenever possible — don't over-specify.

Generics are the foundation of advanced TypeScript patterns and are used extensively in popular libraries like React, Express, and Prisma. Mastering them will make you a significantly more effective TypeScript developer.