TypeScript Generics
JavaScript

TypeScript Generics Explained: A Complete Guide for Developers

Introduction to generics

What are generics in TypeScript?

TypeScript Generics are a powerful feature that allows developers to create reusable and flexible components without losing type safety. They allow you to define functions, classes and interfaces that work with a variety of types rather than just one. This is particularly useful when applying the same logic to different data types.

For example, instead of writing multiple functions for different types, you can write a single generic function that works for all types:

function identity(arg: T): T {
 return arg;
}

Here is a generic type variable, and arg: T means that the function assumes and returns the same type.

Why use generics?

Reusability of code

Generics avoid redundancy by allowing you to write flexible and reusable code components. Instead of duplicating logic for multiple types, generics abstract the type and allow you to apply the same logic to different values.

Type safety

Unlike the any type, generics retain type information, ensuring that TypeScript can still catch errors at compile time. This helps to avoid runtime errors caused by unexpected types.

Improved developer experience

When used correctly, generics provide TypeScript with better autocompletion, error checking and documentation in editors. This makes development smoother and more efficient.

Generics vs. any and unknown

The problem with any

The use of “any” deactivates the type check, which can lead to runtime errors:

function logValue(value: any): void {
 console.log(value.toFixed()); // no compilation error, but runtime error if value is not a number
}

Why unknown is safer than any

The unknown type forces you to perform type checks before using the value, but it doesn’t have the flexibility and inference capabilities of generics.

How generics differ

Generics allow you to maintain the relationship between input and output types:

function wrapInArray(value: T): T[] {
 return [value];
}

Here TypeScript knows that it returns a number[] when you pass a number. If you pass a string, it will return a string[].

When should you use generics?

  • When the same function or class needs to work with different types
  • When you want to preserve type relationships
  • When you use collections such as arrays, maps or stacks
  • When creating libraries or reusable utility programs

Generics are best suited when you are creating components that work with multiple data types while ensuring strict type safety.

General functions

What is a generic function?

A generic function is a function that can work with any data type without losing the specific type information. Instead of hard-coding a specific type, you use a type variable that is replaced by the actual type when the function is used.

Here is a simple example:

function identity(arg: T): T {
 return arg;
}

In this function:

  • Declares a generic type parameter named T.
  • arg: T means that the function accepts an argument of type T.
  • The function returns a value of the same type T.

Type reference in generic functions

TypeScript is often able to infer the type of the generic function automatically, making the function easier to use:

const result = identity("hello"); // derived as string

If required, you can also specify the type explicitly:

const result = identity(42);

Why use generic functions?

Reusability without sacrificing type safety

You can create a version of a function that works across multiple types while maintaining strict typing.

function getFirstElement(arr: T[]): T {
 return arr [0];
}

This function works just as well for arrays with strings, numbers or custom objects.

Retention of input–output type relationships

One of the main advantages of generics over any is that they preserve the type relationship between inputs and outputs.

function toArray(value: T): T[] {
 return [value];
}

const strArray = toArray("hello"); // string[]
const numArray = toArray(123); // number[]

Real-world example: Generic fetch function

Here is a more practical example where a generic function can be used to fetch data from an API and automatically enter the response:

async function fetchData(url: string): Promise {
 const response = await fetch(url);
 const data = await response.json();
 return data as T;
}

// Usage interface User {
 id: number;
 name: string;
}

const user = await fetchData("/api/user");

This pattern ensures that the returned variable user has exactly the form of the interface user and provides you with full autocompletion and type checking.

Naming conventions for type parameters

While T is the most common, you may also see other names:

  • K, V – typically used for keys and values
  • U, S, R – for additional or related types

Choose names that make sense in your context to improve readability, especially for complex functions.

Generic interfaces

What is a generic interface?

A generic interface allows you to define a contract for a structure that works with a variety of data types. Instead of defining specific types, you use a placeholder type that is filled in when the interface is used.

Here is a simple example:

interface Box {
 value: T;
}

This Box interface can now wrap a value of any type while retaining the type information.

const numberBox: Box = { value: 42 };
const stringBox: Box = { value: "hello" };

Why use generic interfaces?

Reusable data structures

Generic interfaces are ideal for creating flexible yet type-safe data structures such as wrappers, containers or results.

interface Result {
 success: boolean;
 data: T;
}

This Result interface can be reused with any type:

const userResult: Result = {
 success: true,
 data: { id: 1, name: "Alice" }
};

Type safety with consistency

Using generics in interfaces ensures that all related data is of the same type, without redundancy or repetition. It also helps to avoid errors by ensuring the consistent use of data types in your application.

Using generic interfaces in functions

You can pass or return generic interfaces in functions just like normal types:

function wrapInBox(value: T): Box {
 return { value };
}

const boxed = wrapInBox("TypeScript"); // Box

This enables maximum reusability and strong typing in utilities.

Optional restrictions with generic interfaces

Sometimes you may want to restrict the types that can be used with your interface. This is where generic constraints come into play.

interface Lengthwise<T extends { length: number }> {
 value: T;
}

Now the interface only accepts types that have a length property, such as strings or arrays:

const strLength: Lengthwise = { value: "hello" };
const arrLength: Lengthwise = { value: [1, 2, 3] };

Attempting to use a type without length results in a compilation error.

Real-World Use Case: API responses

Generic interfaces are often used in front-end applications to model API responses:

interface ApiResponse {
 status: string;
 payload: T;
 error?: string;
}

This pattern enables scalable and consistent typing across all API calls:

interface Product {
 id: number;
 name: string;
}

const productResponse: ApiResponse = {
 status: "success",
 payload: { id: 101, name: "Laptop" }
};

This makes the code base easier to maintain and reduces the likelihood of runtime errors.

Generic classes

What is a generic class?

A generic class is a class that works with different types without compromising type safety. Similar to generic functions and interfaces, generic classes use type parameters to handle values of different types while maintaining strong typing.

Here is a simple example:

class Container {
 private value: T;

 constructor(val: T) {
 this.value = val;
 }

 getValue(): T {
 return this.value;
 }
}

You can now create instances of Container for different types:

const numberContainer = new Container(123);
const stringContainer = new Container("hello");

Why use generic classes?

Reusability and flexibility

Instead of creating separate classes for strings, numbers and other types, you can create one class that works with all types. This avoids duplication and keeps your code DRY.

Strong typing across methods

By using generics, all methods of the class automatically adapt to the specified type, maintaining correctness and avoiding type-related errors.

Example: Generic data storage class

Here is a practical example of a data storage class that can store and manage objects of any type:

class DataStorage {
 private items: T[] = [];

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

 removeItem(item: T) {
 this.items = this.items.filter(i => i !== item);
 }

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

Usage:

const stringStorage = new DataStorage();
stringStorage.addItem("apple");
stringStorage.addItem("banana");

const numberStorage = new DataStorage();
numberStorage.addItem(1);
numberStorage.addItem(2);

General restrictions in classes

Just as with interfaces and functions, you can also add restrictions to generic classes:

class LengthStorage<T extends { length: number }> {
 private items: T[] = [];

 add(item: T) {
 console.log(`Length: ${item.length}`);
 this.items.push(item);
 }
}

This class only accepts elements with a length property (e.g. strings, arrays):

const arrStorage = new LengthStorage();
arrStorage.add([1, 2, 3]);

Attempting to use a number or a Boolean value results in a compilation error.

Advantages and disadvantages of generic classes

Advantages

  • Great for building reusable components and libraries
  • Helps to maintain type relationships between methods
  • Improves maintainability and readability of code

Considerations

  • Can make readability more difficult if it is used too often or is too complex
  • Explicit type annotations may be required in some use cases
  • Debugging deeply nested generics can be challenging without good tools

Effective use of generic classes can greatly improve the robustness and reusability of your TypeScript code, especially when creating shared utilities or working with data structures.

Generic restrictions

What are generic restrictions?

Generic constraints allow you to restrict the types of types that can be used as generic parameters. By default, generic types are completely flexible, which is powerful but sometimes too permissive. With constraints, you can enforce that a generic type has certain properties or extends a certain type.

Here is a simple example of a constraint:

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

This function now only works with types that have a length property, such as strings, arrays or objects with a length field.

logLength("hello"); // works logLength([1, 2, 3]); // works logLength(42); // Error: Number has no 'length' property

Why use constraints?

Enforcing type requirements

Constraints help to enforce a contract on the permitted types, which prevents the misuse of generic functions or classes and increases code security.

Better autocompletion and error checking

When you constrain generics, TypeScript can make more accurate suggestions and detect errors earlier during development.

Constraining on object types

Sometimes you want to make sure that a generic only accepts object-like types. You can achieve this with a constraint like this one:

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

This function ensures that:

  • T must be an object.
  • K must be a key of T.
  • The return type is the value of this key.

Example of use:

const user = { name: "Alice", age: 30 };
const name = getProperty(user, "name"); // string

Attempting to access a non-existent key results in a compilation error:

getProperty(user, "email"); //Error: "email" is not a key of the user

Use of constraints with interfaces and classes

You can also restrict a generic so that it extends a specific interface or class.

interface HasId {
 id: number;
}

function printId<T extends HasId>(item: T) {
 console.log(item.id);
}

Now only values with an id property can be passed:

printId({ id: 101, name: "Laptop" }); // OK printId({ name: "Phone" }); // Error: missing 'id'

Use keyof and extends together

For advanced use cases, you can use “keyof” in constraints to ensure that one generic type is a valid key of another:

function pluck<T, K extends keyof T>(obj: T, keys: K[]): T[K][] {
 return keys.map(key => obj[key]);
}

const user = { name: "Bob", age: 25 };
const result = pluck(user, ["name"]); // result is string[]

This pattern ensures that the keys you access are always valid and thus reduces runtime errors.

Combine multiple constraints

TypeScript does not directly support multiple extends clauses, but you can combine them using intersection types:

function process<T extends { id: number } & { name: string }>(item: T) {
 console.log(item.id, item.name);
}

This requires T to have both id and name properties, so that several restrictions apply.

Constraints are essential if you want to make generics smarter and safer without limiting their flexibility.

Built-in auxiliary types with generics

What are auxiliary types?

Helper types are built-in TypeScript constructs that help to transform or manipulate existing types. Most helper types are built on the generic system of TypeScript. They allow you to write expressive, dry code while ensuring type safety.

These types simplify common type operations, such as making properties optional or read-only or selecting specific keys.

General helper types

Partial

Partial makes all properties of “T” optional.

interface User {
 id: number;
 name: string;
}

const updateUser = (user: Partial) => {
 // user can have id, name, both or neither
};

Use it if you want to partially update objects, like with patch APIs.

Readonly

Readonly makes all properties of T immutable.

const user: Readonly = {
 id: 1,
 name: "Alice"
};

// user.name = "Bob"; Error: Cannot assign 'name' because it is a read-only property

Useful for defining constants or preventing accidental mutations.

Pick

Pick creates a new type by selecting a subset of properties from T.

type UserPreview = Pick<User, "id">;

const preview: UserPreview = { id: 1 };

Ideal for extracting specific views of a larger type.

Omit

Omit creates a new type by omitting one or more properties from T.

type UserWithoutId = Omit<User, "id">;

const newUser: UserWithoutId = { name: "Charlie" };

Useful when you want to reuse a type but exclude certain fields.

Less common but powerful helper types

Data record

Record constructs a type with keys K and values of type T.

type Scores = Record<string, number>;

const gameScores: Scores = {
 player1: 100,
 player2: 95
};

Perfect for cards or dictionary-like structures.

Exclude

with Exclude removes types are removed from T that can be assigned to U.

type Primitive = string | number | boolean;
type NotString = Exclude<Primitive, string>; // number | boolean

Useful for restricting union types.

Excerpt

Extract extracts types from T that can be assigned to U.

type Status = "pending" | "success" | "error";
type ErrorStatus = Extract<Status, "error" | "failed">; // "error"

Helpful for filtering union types.

NonNullable

NonNullable removes null and undefined from a type.

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

Use it to enforce stricter typing in APIs and functions.

Helper types with user-defined generics

You can also compose your own helper types with generics. For example:

type Nullable = T | null;

This allows you to define expressive and flexible type conversions that are tailored to your codebase.

Built-in helper types are a cornerstone of advanced TypeScript usage. They save time, reduce duplication and make your types more expressive while keeping your code clean and readable.

Type reference with generics

What is type inference in generics?

Type inference in TypeScript is the ability of the compiler to automatically determine types from context. In conjunction with generics, this means that TypeScript can often determine the correct type for a generic parameter without the need for explicit annotations.

This makes the code cleaner, more readable and easier to write without losing the benefits of strong typing.

Basic inference in generic functions

When you call a generic function and pass an argument, TypeScript can infer the type of the generic function from that argument:

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

const result = identity("hello"); // derived as string

Here TypeScript infers that T is a string, so the return type of result is also a string.

If necessary, you can still specify the type explicitly:

const result = identity(42);

Inference from several parameters

If a function has multiple parameters, TypeScript uses all available information to infer the generic type:

function merge(a: T, b: T): T[] {
 return [a, b];
}

const merged = merge(1, 2); // T is derived as a number

If the arguments are of different types, TypeScript infers the most specific common type:

const mixed = merge("hello", 42); // T inferred as string | number

Inference with generic constraints

Even when using constraints, TypeScript infers the most suitable subtype that fulfills the constraint:

function getLength<T extends { length: number }>(item: T): number {
 return item.length;
}

getLength("hello"); // string fulfills the condition getLength([1, 2, 3]); // number[] fulfills the condition

Type reference in classes and interfaces

Inference works not only in functions, but also when creating instances of generic classes:

class Box {
 constructor(public value: T) {}
}

const stringBox = new Box("text"); // T derived as string

This reduces the need for detailed type declarations.

Inference with callback functions

When a callback function is passed to a generic function, TypeScript derives the types of the arguments from the function signature:

function mapArray(arr: T[], fn: (item: T) => U): U[] {
 return arr.map(fn);
}

const numbers = [1, 2, 3];
const strings = mapArray(numbers, n => n.toString()); // U derived as string

This enables concise and type-safe conversions.

Overwriting the inference

In some cases, you may want to override the TypeScript-derived type if it is too broad or does not meet your requirements:

function wrap(value: T): T {
 return value;
}

const wrapped = wrap<"fixed">("fixed"); // enforces literal type "fixed"

This can be useful if you want to get certain literals or narrow types.

Type inference with generics is one of the most important features that make TypeScript both powerful and ergonomic. It helps maintain type safety without excessive type annotations, allowing developers to focus on logic while catching type errors early.

General standard parameters

What are generic default parameters?

With generic default parameters, you can specify a default type for a generic type if none is explicitly specified. This makes your generic types and functions more flexible and easier to use, especially in cases where a generic type is normally used.

Default values for generic parameters work just like default function arguments in JavaScript.

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

const result = identity("hello"); // derived as string, uses standard const numberResult = identity(42); // explicitly overrides the standard

If the caller does not specify a type, TypeScript uses the default type.

Why use generic default parameters?

Simplifies the use of functions and classes

If there is a common case that you want to support from the start, standard types reduce the need for additional boilerplate.

Makes APIs more intuitive

When using libraries or utilities, users don’t necessarily need to worry about the general details for the most common scenarios.

Default parameters in functions

Functions with default generic types work like any other generic function:

function wrapValue<T = boolean>(val: T): { value: T } {
 return { value: val };
}

wrapValue(true); // uses the default type boolean wrapValue("test"); // overwrites the default with string

This is helpful if you have a typical use case but still want to allow full customization.

Standard parameters in interfaces and types

You can also use standard parameters in interface and type aliases:

interface ApiResponse {
 data: T;
 error?: string;
}

const response: ApiResponse = {
 data: { message: "Success" }
}; // data is of type `any`

The explicit specification of a type still works:

const userResponse: ApiResponse<{ name: string }> = {
 data: { name: "Alice" }
};

Standard parameters in classes

Classes also support generic standard parameters:

class Container<T = string> {
 value: T;
 constructor(val: T) {
 this.value = val;
 }
}

const defaultContainer = new Container("text"); // T is String by default const numberContainer = new Container(123); // overrides default

This reduces repetitions and makes the class instantiation cleaner for the most common case.

Combination of standard parameters with constraints

You can also specify default values when using constraints:

function createMap<K extends string = string, V = number>(): Record {
 return {} as Record;
}

const defaultMap = createMap(); // data set<string, number>
const customMap = createMap<"id", string>(); // data set<"id", string>

This combines the advantages of constraints and meaningful specifications.

Generic default parameters are a powerful feature that makes TypeScript code more user-friendly, more flexible and less tedious — especially in libraries and codebases with many utilities.

Combine generics

Why combine generics?

Combining generics allows you to write flexible and reusable components that can handle multiple types while preserving the relationships between them. This is particularly useful when it comes to converting one type to another, mapping keys to values or linking input and output types.

In TypeScript, you can declare multiple generic parameters and define how they relate to each other. This enables powerful type-safe abstractions.

Declaring multiple generic parameters

You can declare multiple generic parameters by separating them with commas:

function pair(first: T, second: U): [T, U] {
 return [first, second];
}

const result = pair("hello", 42); // [string, number]

Each type is inferred independently or can be specified explicitly:

const explicitPair = pair<string, boolean>("yes", true);

Generics with relationships

You can define relationships between generic parameters with constraints:

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

const user = { id: 1, name: "Alice" };
const value = getKeyValue(user, "name"); // string

Here, K is constrained to be a valid key of T, and the return type is the value of this key. This maintains a tight coupling between the types.

Nested generics

Generics can be nested, e.g. by passing one generic as a parameter to another:

function wrapArray(value: T): Array {
 return [value];
}

const wrapped = wrapArray({ id: 1 }); // Array<{ id: number }>

You can also compose your own types:

type response = {
 data: T;
 success: boolean;
};

function createResponse(data: T): Response {
 return { data, success: true };
}

Combining generics in classes

When modeling complex relationships, classes often benefit from several generics:

class KeyValuePair {
 constructor(public key: K, public value: V) {}
}

const entry = new KeyValuePair<string, number>("age", 30);

This allows developers to define strongly typed containers or utilities with a minimum of boilerplate.

Combination of generics in interfaces

Interfaces can also combine several generics to define flexible structures:

interface Dictionary<K extends string | number, V> {
 [key: K]: V;
}

const userAges: Dictionary<string, number> = {
 alice: 25,
 bob: 30
};

This is useful for generic maps, registries or configuration objects.

Best practices for the combination of generics

Use descriptive names

Avoid single-letter names like ` in public APIs. Use descriptive names likeor` for clarity.

Keep the relationships clear

Use constraints and dependent types (extends, keyof) to express how types relate to each other. This improves maintainability and support for auto-completion.

Don’t overdo it

If a function has too many generic parameters, you should split it into smaller parts or simplify the API. Too many generic parameters can make the code harder to read and maintain.

The combination of generics fully utilizes the potential of the TypeScript type system and enables the creation of safe, expressive and reusable code structures.

Conclusion

TypeScript generics are one of the most powerful tools in a developer’s toolbox for creating scalable, type-safe and reusable code. By abstracting types while maintaining strict type checking, generics allow you to write flexible functions, components, classes and data structures without sacrificing security or clarity.

Whether you’re working with helper types, creating dynamic interfaces or combining multiple type parameters, mastering generics leads to cleaner APIs and safer refactoring. As you delve further into advanced features like constraints, default parameters, and type inference, you’ll find that generics can drastically improve both code quality and developer experience.

Start small, experiment often, and soon you’ll be able to utilize the full expressive power of the TypeScript type system in any project.