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:
- Use
any— which disables type checking entirely and defeats the purpose of TypeScript. - 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
anywhenever 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, andRecordinstead 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.