TypeScript Generics and Utility Types
TypeScript's type system is one of the most powerful features available to modern JavaScript developers — and at the heart of it lie two deeply complementary concepts: generics and utility types. When you learn to leverage these tools effectively, you can write code that is simultaneously flexible, reusable, and completely type-safe, without ever resorting to the dreaded any escape hatch.
In this guide, we'll start from the ground up — understanding why generics exist and what problem they solve — then progressively build toward advanced patterns like conditional types, mapped types, and custom utility types. By the end, you'll have the vocabulary and tools to express even complex type relationships clearly and confidently.
What Are TypeScript Generics?
Before generics existed, developers writing reusable functions in TypeScript had to make an uncomfortable tradeoff. Consider an identity function — a function that simply returns whatever you pass it. Without generics, you'd write it like this:
// Option 1: Use a specific type — not reusable
function identityNumber(arg: number): number {
return arg;
}
// Option 2: Use any — no type safety at all
function identity(arg: any): any {
return arg;
}
const result = identity(42);
// TypeScript thinks result is `any` — all type info is lost
result.toUpperCase(); // No error, but this will crash at runtime!
The problem with any is that it completely disables type checking. You lose all autocomplete, refactoring support, and compile-time error detection. Generics solve this by introducing a type parameter — a placeholder that gets replaced with the actual type at the call site.
// Generic identity function — T is the type parameter
function identity<T>(arg: T): T {
return arg;
}
// TypeScript infers T = number
const num = identity(42); // type: number
const str = identity("hello"); // type: string
const arr = identity([1, 2, 3]); // type: number[]
// You can also explicitly pass the type
const explicit = identity<boolean>(true); // type: boolean
// Generic function with array
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
const first = firstElement([10, 20, 30]); // type: number
const word = firstElement(["a", "b"]); // type: string
Generics enable you to build flexible, type-safe code without using any, which would sacrifice all type checking. Think of a type parameter like a variable — but for types, not values. TypeScript infers it automatically from usage, so you rarely need to specify it explicitly.
How generics preserve type information through a function
Generic Functions and Interfaces
Generics aren't limited to single-type functions. You can define multiple type parameters, generic interfaces, and even set default types for type parameters.
Multiple Type Parameters
// Pair function with two type parameters
function pair<A, B>(first: A, second: B): [A, B] {
return [first, second];
}
const p1 = pair(1, "one"); // [number, string]
const p2 = pair(true, { x: 10 }); // [boolean, { x: number }]
// Swap function
function swap<A, B>(tuple: [A, B]): [B, A] {
return [tuple[1], tuple[0]];
}
const swapped = swap(["hello", 42]); // [number, string]
Generic Interfaces
Interfaces can also use type parameters, which is essential for creating reusable data structures and contracts across your application.
// Generic Repository interface
interface Repository<T> {
findById(id: number): Promise<T | null>;
findAll(): Promise<T[]>;
create(data: Omit<T, 'id'>): Promise<T>;
update(id: number, data: Partial<T>): Promise<T>;
delete(id: number): Promise<void>;
}
// Generic paginated response — default type is unknown
interface PaginatedResponse<T = unknown> {
data: T[];
total: number;
page: number;
perPage: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
}
// Usage
const usersPage: PaginatedResponse<User> = {
data: [...],
total: 250,
page: 1,
perPage: 20,
totalPages: 13,
hasNext: true,
hasPrev: false
};
// Generic arrow function syntax (note the trailing comma to avoid JSX ambiguity)
const wrap = <T,>(value: T): { value: T } => ({ value });
Learning generics follows a clear progression. Here are four steps to building mastery:
Start with Simple Type Parameters
Write generic identity functions and wrappers. Get comfortable with the <T> syntax and understand how TypeScript infers the type from the argument.
Add Constraints with extends
Use T extends SomeType to restrict what types are allowed, enabling you to safely access properties and methods on the generic value.
Master Built-in Utility Types
Learn Partial, Pick, Omit, Record, and others. These are the daily workhorses of TypeScript development.
Create Custom Utility Types
Combine conditional types, mapped types, and infer to build your own reusable type transformations tailored to your codebase.
Generic Constraints
Sometimes you want to be generic, but not completely generic. You need to be able to access certain properties on the type parameter. This is where the extends keyword comes in for constraints.
The extends Keyword
// Without constraint — TypeScript doesn't know T has .length
function logLength<T>(arg: T): T {
console.log(arg.length); // Error: Property 'length' does not exist on type 'T'
return arg;
}
// With constraint — T must have a length property
function logLength<T extends { length: number }>(arg: T): T {
console.log(arg.length); // OK!
return arg;
}
logLength("hello"); // OK — strings have .length
logLength([1, 2, 3]); // OK — arrays have .length
logLength({ length: 5 }); // OK — object matches the shape
logLength(42); // Error — numbers don't have .length
// keyof constraint — get a property safely
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Mayur", age: 28, email: "m@example.com" };
const name = getProperty(user, "name"); // type: string
const age = getProperty(user, "age"); // type: number
// getProperty(user, "phone"); // Error — "phone" not in keyof typeof user
Conditional Types
TypeScript's conditional types bring if/else logic into the type system. The syntax T extends U ? X : Y reads as: "If T is assignable to U, the type is X; otherwise it's Y."
// Basic conditional type
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
// Extract the element type from an array using infer
type ArrayElement<T> = T extends (infer U)[] ? U : never;
type NumEl = ArrayElement<number[]>; // number
type StrEl = ArrayElement<string[]>; // string
type Never = ArrayElement<string>; // never (not an array)
// Unwrap a Promise
type Awaited<T> = T extends Promise<infer U> ? U : T;
type Resolved = Awaited<Promise<string>>; // string
type Direct = Awaited<number>; // number (not a promise)
// Non-nullable type
type NonNullable<T> = T extends null | undefined ? never : T;
type Safe = NonNullable<string | null | undefined>; // string
Over-constraining generics defeats their purpose. Only add constraints when you actually need to access specific properties on the generic type. If you find yourself adding many constraints, consider whether a specific type or union type would be more appropriate than a generic.
Built-in TypeScript Utility Types
TypeScript ships with a powerful set of utility types that transform existing types into new ones. These are the bread and butter of everyday TypeScript development — you'll use them constantly once you know them.
| Utility Type | Purpose | Example Usage |
|---|---|---|
Partial<T> |
Makes all properties optional | Update DTOs, patch requests |
Required<T> |
Makes all properties required | Ensure fully-populated objects |
Readonly<T> |
Makes all properties read-only | Immutable config objects |
Pick<T, K> |
Select a subset of keys from T | Public-facing DTOs |
Omit<T, K> |
Remove specific keys from T | Exclude sensitive fields |
Record<K, V> |
Map keys K to values of type V | Dictionaries and lookup maps |
Exclude<T, U> |
Remove U from a union type T | Filter union members |
NonNullable<T> |
Remove null and undefined from T | Assert value is present |
ReturnType<T> |
Extract the return type of a function | Infer API response shapes |
Let's see all of these applied to a single User interface to make the differences concrete:
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
}
// Partial — for PATCH/update requests (all fields optional)
type UpdateUserDto = Partial<User>;
// { id?: number; name?: string; email?: string; ... }
// Pick — select only the fields you need
type UserPreview = Pick<User, 'id' | 'name' | 'email'>;
// { id: number; name: string; email: string }
// Omit — remove sensitive fields before sending to client
type PublicUser = Omit<User, 'password'>;
// { id: number; name: string; email: string; createdAt: Date }
// Omit for creation — id and createdAt are generated server-side
type CreateUserDto = Omit<User, 'id' | 'createdAt'>;
// { name: string; email: string; password: string }
// Record — map string keys to role values
type UserRoles = Record<string, 'admin' | 'user' | 'moderator'>;
// { [key: string]: 'admin' | 'user' | 'moderator' }
const roles: UserRoles = {
"user_123": "admin",
"user_456": "user",
};
// ReturnType — infer the return type of an existing function
async function getUser(id: number): Promise<User> {
return fetch(`/api/users/${id}`).then(r => r.json());
}
type GetUserReturn = Awaited<ReturnType<typeof getUser>>;
// User
// Readonly — prevent mutation
const config: Readonly<{ apiUrl: string; timeout: number }> = {
apiUrl: "https://api.example.com",
timeout: 5000,
};
// config.apiUrl = "other"; // Error: Cannot assign to 'apiUrl' — it is read-only
Creating Custom Utility Types
While built-in utility types cover most scenarios, real-world codebases often require custom transformations. TypeScript's mapped types and conditional types give you everything you need to build these yourself.
DeepPartial — Recursive Partial
The built-in Partial<T> only makes top-level properties optional. For deeply nested objects, you need a recursive version:
// Built-in Partial only goes one level deep
type Partial<T> = {
[K in keyof T]?: T[K]; // Maps over each key, making it optional
};
// DeepPartial — recursively applies Partial to nested objects
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
interface AppConfig {
server: {
host: string;
port: number;
ssl: {
enabled: boolean;
cert: string;
};
};
database: {
url: string;
poolSize: number;
};
}
// With built-in Partial, you'd have to provide the full server object
// With DeepPartial, every nested property is also optional
type PartialConfig = DeepPartial<AppConfig>;
const partial: PartialConfig = {
server: {
port: 8080 // ssl and host can be omitted
}
// database can be omitted entirely
};
Nullable, KeysOfType, and More
// Simple nullable shorthand
type Nullable<T> = T | null;
const userId: Nullable<number> = null; // OK
// KeysOfType — get keys of T whose values are of type V
// Useful for extracting all string fields, all numeric fields, etc.
type KeysOfType<T, V> = {
[K in keyof T]-?: T[K] extends V ? K : never;
}[keyof T];
interface Product {
id: number;
name: string;
price: number;
description: string;
inStock: boolean;
}
type StringKeys = KeysOfType<Product, string>; // 'name' | 'description'
type NumberKeys = KeysOfType<Product, number>; // 'id' | 'price'
type BooleanKeys = KeysOfType<Product, boolean>; // 'inStock'
// MakeRequired — make specific keys required while keeping others as-is
type MakeRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
interface DraftPost {
title?: string;
body?: string;
tags?: string[];
publishedAt?: Date;
}
// To publish, title and body must be present
type PublishedPost = MakeRequired<DraftPost, 'title' | 'body'>;
// title: string (required), body: string (required), tags?: string[], publishedAt?: Date
Understanding how mapped types work is key to building your own utilities. The pattern [K in keyof T]?: ... iterates over every key in T, and you can transform both the key (using as remapping) and the value type:
// Anatomy of a mapped type:
// [K in keyof T] — iterate over all keys of T
// ?: — make the property optional (remove with -?)
// T[K] — preserve the original value type
// Example: Convert all values to strings (getter pattern)
type Stringify<T> = {
[K in keyof T]: string;
};
// Example: Add a prefix to all keys using key remapping (TS 4.1+)
type PrefixKeys<T, Prefix extends string> = {
[K in keyof T as `${Prefix}${Capitalize<string & K>}`]: T[K];
};
interface User { name: string; email: string; }
type PrefixedUser = PrefixKeys<User, 'user'>;
// { userName: string; userEmail: string }
Real-World Applications
Theory is only as useful as its application. Let's look at three common real-world patterns where generics and utility types shine.
API Response Wrapper
Most APIs return data in a consistent envelope. A generic wrapper type ensures you get type safety all the way from the HTTP response down to your UI:
// Generic API response wrapper
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: string;
}
// Result type for error handling (similar to Rust's Result)
type ApiResult<T> =
| { success: true; data: T; error: null }
| { success: false; data: null; error: string };
// Generic fetch helper
async function apiFetch<T>(url: string): Promise<ApiResult<T>> {
try {
const response = await fetch(url);
if (!response.ok) {
return { success: false, data: null, error: `HTTP ${response.status}` };
}
const json: ApiResponse<T> = await response.json();
return { success: true, data: json.data, error: null };
} catch (err) {
return { success: false, data: null, error: String(err) };
}
}
// Usage — fully typed
const result = await apiFetch<User[]>('/api/users');
if (result.success) {
result.data.forEach(user => console.log(user.name)); // user: User — fully typed!
} else {
console.error(result.error);
}
Generic Repository Pattern
// Base entity constraint — must have an id
interface Entity {
id: number;
}
// Abstract generic base repository
abstract class BaseRepository<T extends Entity> {
protected abstract tableName: string;
protected items: T[] = [];
findById(id: number): T | undefined {
return this.items.find(item => item.id === id);
}
findAll(): T[] {
return [...this.items];
}
create(data: Omit<T, 'id'>): T {
const newItem = { ...data, id: this.items.length + 1 } as T;
this.items.push(newItem);
return newItem;
}
update(id: number, data: Partial<Omit<T, 'id'>>): T | undefined {
const index = this.items.findIndex(item => item.id === id);
if (index === -1) return undefined;
this.items[index] = { ...this.items[index], ...data };
return this.items[index];
}
delete(id: number): boolean {
const index = this.items.findIndex(item => item.id === id);
if (index === -1) return false;
this.items.splice(index, 1);
return true;
}
}
// Concrete repository — just extend and specify the type
class UserRepository extends BaseRepository<User> {
protected tableName = 'users';
findByEmail(email: string): User | undefined {
return this.items.find(u => u.email === email);
}
}
const userRepo = new UserRepository();
const newUser = userRepo.create({ name: "Mayur", email: "m@example.com", password: "hashed", createdAt: new Date() });
// newUser is fully typed as User
React Generic Component
One of the most powerful uses of generics in a React application is building truly reusable components that work with any data type:
import React from 'react';
interface SelectProps<T> {
options: T[];
value: T | null;
onChange: (value: T) => void;
getLabel: (option: T) => string;
getValue: (option: T) => string | number;
placeholder?: string;
}
// Generic Select — works with any option type
function Select<T,>({ options, value, onChange, getLabel, getValue, placeholder }: SelectProps<T>) {
return (
<select
value={value ? String(getValue(value)) : ''}
onChange={(e) => {
const selected = options.find(o => String(getValue(o)) === e.target.value);
if (selected) onChange(selected);
}}
>
{placeholder && <option value="">{placeholder}</option>}
{options.map(option => (
<option key={String(getValue(option))} value={String(getValue(option))}>
{getLabel(option)}
</option>
))}
</select>
);
}
// Usage with User objects
<Select<User>
options={users}
value={selectedUser}
onChange={setSelectedUser}
getLabel={(u) => u.name}
getValue={(u) => u.id}
placeholder="Select a user"
/>
// Usage with plain strings — same component, different type
<Select<string>
options={['admin', 'user', 'moderator']}
value={selectedRole}
onChange={setSelectedRole}
getLabel={(r) => r}
getValue={(r) => r}
/>
Generic type flow: from concrete type to type-safe output
Conclusion
TypeScript generics and utility types are not just advanced features reserved for library authors — they are everyday tools that every professional TypeScript developer should reach for regularly. As your codebase grows, the ability to express complex type relationships concisely becomes increasingly valuable.
Key Takeaways
- Generics replace
any: Whenever you'd reach foranyto make a function flexible, a type parameter is almost always the right solution instead. - Constraints enable access: Use
T extends Somethingwhen you need to access properties on the generic type. Only constrain what you actually need. - Utility types are daily drivers:
Partial,Pick,Omit, andRecordshould be part of your natural TypeScript vocabulary for defining DTOs and transformations. - Mapped types are composable: The
[K in keyof T]pattern lets you build powerful custom transformations by mapping over object keys. - Conditional types enable logic:
T extends U ? X : Ybrings if/else expressiveness into the type system for advanced scenarios. - Generics work everywhere: Functions, interfaces, classes, React components — generics are universally applicable in TypeScript.
Start small — replace your next any with a type parameter and see the ripple effect of improved type safety through your codebase. Gradually incorporate the utility types as you encounter the problems they solve. TypeScript's type system rewards investment: the more you put in, the more confidence and tooling support you get out.