TS
Frontend

TypeScript Generics and Utility Types

Mayur Dabhi
Mayur Dabhi
May 27, 2026
14 min read

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:

TypeScript — without generics
// 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.

TypeScript — with generics
// 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
Key Insight

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.

Input Value T = string type inferred Generic Function identity<T> (arg: T): T type preserved Output T still string ✓ Type information flows through the generic function unchanged

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

TypeScript — 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.

TypeScript — generic interfaces
// 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:

1

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.

2

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.

3

Master Built-in Utility Types

Learn Partial, Pick, Omit, Record, and others. These are the daily workhorses of TypeScript development.

4

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

TypeScript — generic constraints
// 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."

TypeScript — conditional types
// 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
Common Pitfall

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:

TypeScript — utility types in practice
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:

TypeScript — DeepPartial custom utility
// 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

TypeScript — more custom utility types
// 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:

TypeScript — mapped type anatomy
// 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:

TypeScript — generic API response types
// 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

TypeScript — abstract generic repository
// 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:

TypeScript — generic React Select component
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}
/>
Concrete Type User, Product Type Parameter T Generic Container Repository<T> ApiResponse<T> Select<T> Type-safe Output How concrete types flow through generic containers to produce type-safe outputs

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 for any to make a function flexible, a type parameter is almost always the right solution instead.
  • Constraints enable access: Use T extends Something when you need to access properties on the generic type. Only constrain what you actually need.
  • Utility types are daily drivers: Partial, Pick, Omit, and Record should 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 : Y brings 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.

TypeScript Generics Advanced Utility Types Frontend Type Safety
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.