liminfo

TypeScript Generic Patterns

Learn practical TypeScript generic patterns step by step: reusable utility types, conditional types, type guards, and ensuring API type safety

TypeScript genericsgeneric patternsconditional typesTypeScript utility typesmapped typestype guardtype inferencetemplate literal types

Problem

In a large TypeScript project, code for API response handling, form validation, and state management is being duplicated for each type. API responses have different shapes per endpoint, but you want to abstract the common patterns (loading/error/success) in a type-safe way. You need to create reusable utility functions and types with generics, with full type inference without any or as type assertions. Generics should be designed with an intuitive interface that team members can easily use.

Required Tools

TypeScript Generics

A core TypeScript feature for creating reusable components using type parameters.

Conditional Types

Type-level conditionals (T extends U ? X : Y) that dynamically determine output types based on input types.

Mapped Types

Iterate over properties of an existing type to create new types. Partial, Required, Pick are representative examples.

Template Literal Types

Combine string literal types to create pattern-based types.

Type Guard (is, asserts)

A pattern that informs the TypeScript compiler of runtime type check results to narrow types.

Solution Steps

1

Generic basics: type variables and constraints

The core of generics is 'accepting types as parameters.' Adding constraints with the extends keyword allows only specific type shapes and enables safe property access within the generic. Combined with keyof, it enables type-safe access to object keys.

// --- 1. Basic generic function ---
function identity<T>(value: T): T {
  return value;
}
const num = identity(42);        // T = number (auto-inferred)
const str = identity('hello');   // T = string

// --- 2. Constraints (extends) ---
function getLength<T extends { length: number }>(item: T): number {
  return item.length;  // T is guaranteed to have length
}
getLength('hello');     // OK
getLength([1, 2, 3]);   // OK
// getLength(42);       // Error: number has no length

// --- 3. Type-safe property access with keyof ---
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: 'Kim', age: 30, email: 'kim@test.com' };
const name = getProperty(user, 'name');   // string
const age = getProperty(user, 'age');     // number
// getProperty(user, 'phone');             // Error: 'phone' not in keyof User

// --- 4. Multiple type variables ---
function merge<T extends object, U extends object>(a: T, b: U): T & U {
  return { ...a, ...b };
}
const merged = merge({ name: 'Kim' }, { age: 30 });
// Type: { name: string } & { age: number }

// --- 5. Generics with defaults ---
interface ApiResponse<T = unknown> {
  data: T;
  status: number;
  message: string;
}

const res1: ApiResponse<{ id: number }> = { data: { id: 1 }, status: 200, message: 'OK' };
const res2: ApiResponse = { data: 'anything', status: 200, message: 'OK' }; // T = unknown
2

Implementing utility types from scratch

Understanding how built-in utility types (Partial, Required, Pick, etc.) are implemented enables you to freely create custom utility types. Combine Mapped Types and Conditional Types to implement type transformation tools needed in practice.

// --- Utility types with Mapped Types ---

// Partial<T> implementation: make all properties optional
type MyPartial<T> = {
  [P in keyof T]?: T[P];
};

// Required<T> implementation: make all properties required
type MyRequired<T> = {
  [P in keyof T]-?: T[P];  // -? removes optional modifier
};

// --- Practical utility types ---

// Make only specific keys optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

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

// Only id is optional, rest required
type CreateUserInput = PartialBy<User, 'id'>;
// { name: string; email: string; age: number; id?: number }

// Make only specific keys required
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;

// Deep Partial (all nested objects also optional)
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

interface Config {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
}

type PartialConfig = DeepPartial<Config>;

// Nullable type
type Nullable<T> = { [P in keyof T]: T[P] | null };

// Extract value types
type ValueOf<T> = T[keyof T];
type UserValues = ValueOf<User>;  // number | string
3

Conditional Types and infer

Conditional types are type-level if-else, returning different types based on input types. The infer keyword is a powerful tool for extracting types within conditional types. It can extract function return types, Promise resolution types, array element types, and more.

// --- Conditional Types basics ---
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>;  // true
type B = IsString<42>;       // false

// --- Type extraction with infer ---

// Extract function return type (ReturnType implementation)
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type FnReturn = MyReturnType<() => string>;  // string

// Extract Promise resolution type
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
type ResolvedType = Awaited<Promise<Promise<string>>>;  // string (recursively unwrapped)

// Extract array element type
type ElementType<T> = T extends (infer U)[] ? U : never;
type Elem = ElementType<string[]>;  // string

// --- Practical: API response type system ---
interface ApiEndpoints {
  '/users': User[];
  '/users/:id': User;
  '/posts': Post[];
  '/posts/:id': Post;
}

type ApiResponseType<T extends keyof ApiEndpoints> = ApiEndpoints[T];

function fetchApi<T extends keyof ApiEndpoints>(
  endpoint: T
): Promise<ApiResponseType<T>> {
  return fetch(endpoint).then(r => r.json());
}

// Types are automatically inferred
const users = await fetchApi('/users');     // User[]
const user = await fetchApi('/users/:id');  // User

// --- Conditional type branching ---
type MessageFor<T> = T extends 'success'
  ? { data: unknown; status: 200 }
  : T extends 'error'
    ? { error: string; status: number }
    : T extends 'loading'
      ? { progress: number }
      : never;
4

Generic classes and factory patterns

Generic classes enable type-safe collections, repository patterns, and state management stores. Applying generics to factory functions and builder patterns maintains type inference even through method chaining.

// --- Generic Repository Pattern ---
interface Entity {
  id: string;
  createdAt: Date;
  updatedAt: Date;
}

class Repository<T extends Entity> {
  private items: Map<string, T> = new Map();

  create(data: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): T {
    const now = new Date();
    const entity = {
      ...data,
      id: crypto.randomUUID(),
      createdAt: now,
      updatedAt: now,
    } as T;
    this.items.set(entity.id, entity);
    return entity;
  }

  findById(id: string): T | undefined {
    return this.items.get(id);
  }

  findAll(predicate?: (item: T) => boolean): T[] {
    const all = Array.from(this.items.values());
    return predicate ? all.filter(predicate) : all;
  }

  update(id: string, data: Partial<Omit<T, 'id' | 'createdAt'>>): T | undefined {
    const existing = this.items.get(id);
    if (!existing) return undefined;
    const updated = { ...existing, ...data, updatedAt: new Date() };
    this.items.set(id, updated);
    return updated;
  }
}

// Usage
interface UserEntity extends Entity {
  name: string;
  email: string;
  role: 'admin' | 'user';
}

const userRepo = new Repository<UserEntity>();
const newUser = userRepo.create({ name: 'Kim', email: 'kim@test.com', role: 'user' });
// Type: UserEntity (id, createdAt, updatedAt included automatically)

// --- Generic Builder Pattern ---
class QueryBuilder<T extends Entity> {
  private filters: Array<(item: T) => boolean> = [];
  private sortKey?: keyof T;
  private limitCount?: number;

  where<K extends keyof T>(key: K, value: T[K]): this {
    this.filters.push(item => item[key] === value);
    return this;  // method chaining
  }

  orderBy(key: keyof T): this {
    this.sortKey = key;
    return this;
  }

  limit(count: number): this {
    this.limitCount = count;
    return this;
  }

  execute(data: T[]): T[] {
    let result = data.filter(item => this.filters.every(f => f(item)));
    return result;
  }
}

// Type-safe queries
new QueryBuilder<UserEntity>()
  .where('role', 'admin')    // Both key and value types are validated
  .orderBy('createdAt')
  .limit(10)
  .execute(users);
5

Type guards and Discriminated Unions

Use type guards and discriminated union patterns to safely narrow types at runtime. Creating custom type guards with the is keyword automatically narrows the type in subsequent code blocks.

// --- Discriminated Union (tagged union) ---
type Result<T, E = Error> =
  | { success: true; data: T }
  | { success: false; error: E };

function processResult<T>(result: Result<T>): T | null {
  if (result.success) {
    // Auto-narrowed to { success: true; data: T }
    return result.data;
  } else {
    // { success: false; error: Error }
    console.error(result.error.message);
    return null;
  }
}

// --- Custom type guard (is) ---
interface Cat { type: 'cat'; meow(): void; }
interface Dog { type: 'dog'; bark(): void; }
type Animal = Cat | Dog;

function isCat(animal: Animal): animal is Cat {
  return animal.type === 'cat';
}

function handleAnimal(animal: Animal) {
  if (isCat(animal)) {
    animal.meow();  // Narrowed to Cat
  } else {
    animal.bark();  // Narrowed to Dog
  }
}

// --- Generic type guard ---
function isNotNull<T>(value: T | null | undefined): value is T {
  return value !== null && value !== undefined;
}

const items: (string | null)[] = ['a', null, 'b', null, 'c'];
const filtered = items.filter(isNotNull);
// Type: string[] (null removed)

// --- API response pattern (Result + generics) ---
type AsyncResult<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function renderResult<T>(
  result: AsyncResult<T>,
  renderData: (data: T) => string
): string {
  switch (result.status) {
    case 'idle': return 'Idle';
    case 'loading': return 'Loading...';
    case 'success': return renderData(result.data);
    case 'error': return `Error: ${result.error}`;
  }
}
6

Template Literal Types and advanced patterns

Template Literal Types generate string-based type patterns. You can constrain types for event names, API paths, CSS properties, and more using string patterns. Combined with recursive types, you can implement complex type systems like JSON parsers and router pattern matching.

// --- Template Literal Types ---
type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickEvent = EventName<'click'>;   // 'onClick'
type ChangeEvent = EventName<'change'>; // 'onChange'

// Auto-generate event handler types
type EventHandlers<T extends Record<string, any>> = {
  [K in keyof T as `on${Capitalize<string & K>}`]: (value: T[K]) => void;
};

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

type FormHandlers = EventHandlers<FormFields>;
// { onName: (value: string) => void;
//   onAge: (value: number) => void;
//   onEmail: (value: string) => void; }

// --- Path parameter extraction ---
type ExtractParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<Rest>
    : T extends `${string}:${infer Param}`
      ? Param
      : never;

type Params = ExtractParams<'/users/:userId/posts/:postId'>;
// 'userId' | 'postId'

// Type-safe router
function createRoute<T extends string>(
  path: T,
  handler: (params: Record<ExtractParams<T>, string>) => void
) {
  // implementation...
}

createRoute('/users/:userId/posts/:postId', (params) => {
  params.userId;   // OK
  params.postId;   // OK
  // params.other; // Error!
});

// --- Recursive type: JSON serializable type ---
type JsonValue =
  | string
  | number
  | boolean
  | null
  | JsonValue[]
  | { [key: string]: JsonValue };

Core Code

Type-safe API client combining endpoint mapping with generics. Request options and response types are auto-inferred just from the endpoint string.

// === Core: Type-safe API Client (Generics comprehensive) ===

// 1. API endpoint type mapping
interface ApiMap {
  'GET /users': { response: User[]; query: { role?: string } };
  'GET /users/:id': { response: User; params: { id: string } };
  'POST /users': { response: User; body: CreateUserInput };
  'PUT /users/:id': { response: User; params: { id: string }; body: Partial<User> };
  'DELETE /users/:id': { response: void; params: { id: string } };
}

// 2. Auto-infer option types from method/path
type ApiOptions<K extends keyof ApiMap> = Omit<ApiMap[K], 'response'>;
type ApiResult<K extends keyof ApiMap> = ApiMap[K]['response'];

// 3. Type-safe API call
async function api<K extends keyof ApiMap>(
  endpoint: K,
  options: ApiOptions<K>
): Promise<Result<ApiResult<K>>> {
  try {
    const data = await fetch(/* ... */).then(r => r.json());
    return { success: true, data };
  } catch (e) {
    return { success: false, error: e as Error };
  }
}

// 4. Usage: types fully auto-inferred
const result = await api('GET /users', { query: { role: 'admin' } });
if (result.success) {
  result.data; // User[] type auto-inferred
}

Common Mistakes

Setting any as the generic default, nullifying type safety

Use unknown instead of any as a generic default. unknown requires type checking before use, maintaining type safety. Example: interface Response<T = unknown>

Excessive generic nesting making type error messages incomprehensible

When generic nesting exceeds 3 levels, introduce intermediate type aliases for readability. Document complex type operations with comments showing example types to help team members understand.

Overusing as type assertions causing runtime errors

Prefer type guards (is) or the satisfies operator whenever possible. as deceives the compiler, so runtime errors occur if types differ. Only use it when absolutely necessary and leave a comment explaining why.

Related liminfo Services