Back to Blog

TypeScript Advanced Patterns: Generics, Utility Types, and Type-Safe APIs

Master TypeScript advanced patterns — conditional types, mapped types, template literal types, branded types, and type-safe API clients. Real examples for produ

Viprasol Tech Team
March 25, 2026
13 min read

TypeScript Advanced Patterns: Generics, Utility Types, and Type-Safe APIs

TypeScript's basic types — string, number, interface, type — cover 80% of everyday code. The other 20% is where TypeScript gets genuinely powerful: conditional types that branch on type relationships, mapped types that transform shapes, branded types that prevent logic errors the runtime can't catch, and generic constraints that make library code type-safe without sacrificing flexibility.

This guide covers the patterns that show up in production TypeScript codebases: the patterns our team reaches for when building type-safe API clients, validation libraries, and shared utilities.


Generics Beyond the Basics

Most developers use generics for collections: Array<T>, Promise<T>. The real leverage comes from using type parameters to propagate type information through functions.

Generic with constraint:

// Without constraint — T can be anything, no property access
function getProperty<T>(obj: T, key: string): unknown {
  return (obj as any)[key];  // Forced to use any
}

// With keyof constraint — T must have key K
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];  // Fully typed — return type is T[K]
}

const user = { id: 1, name: 'Alice', email: 'alice@example.com' };
const name = getProperty(user, 'name');   // type: string ✅
const id = getProperty(user, 'id');       // type: number ✅
// getProperty(user, 'phone');            // Error: not in keyof User ✅

Generic with default type parameter (TypeScript 5.5+):

// Reusable paginated response — generic with sensible default
interface PaginatedResponse<T = Record<string, unknown>> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
  hasNextPage: boolean;
}

// Without explicit T — returns PaginatedResponse<Record<string, unknown>>
// With T — fully typed
type UserListResponse = PaginatedResponse<User>;
type ProductListResponse = PaginatedResponse<Product>;

Conditional Types

Conditional types let you create types that branch based on type relationships — the extends check acts like a ternary for types.

// Basic conditional type
type IsArray<T> = T extends any[] ? true : false;
type A = IsArray<string[]>;  // true
type B = IsArray<string>;    // false

// Infer — extract types from within other types
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type C = UnwrapPromise<Promise<string>>;  // string
type D = UnwrapPromise<string>;           // string (unchanged)

// Practical: extract return type of any async function
type AsyncReturnType<T extends (...args: any[]) => Promise<any>> =
  T extends (...args: any[]) => Promise<infer R> ? R : never;

async function fetchUser(id: string) {
  return { id, name: 'Alice', email: 'alice@example.com' };
}

type FetchedUser = AsyncReturnType<typeof fetchUser>;
// type FetchedUser = { id: string; name: string; email: string; }

// Distributive conditional types — distribute over union members
type NonNullable<T> = T extends null | undefined ? never : T;
type E = NonNullable<string | null | undefined>;  // string

Practical example — API error handling:

type Result<T, E extends Error = Error> =
  | { ok: true; data: T }
  | { ok: false; error: E };

// Type-safe unwrap
function unwrap<T>(result: Result<T>): T {
  if (!result.ok) throw result.error;
  return result.data;
}

// Narrow based on ok
async function getUser(id: string): Promise<Result<User>> {
  try {
    const user = await db.user.findUnique({ where: { id } });
    if (!user) return { ok: false, error: new Error('User not found') };
    return { ok: true, data: user };
  } catch (err) {
    return { ok: false, error: err as Error };
  }
}

const result = await getUser('123');
if (result.ok) {
  console.log(result.data.name);  // TypeScript knows data exists
} else {
  console.error(result.error.message);  // TypeScript knows error exists
}

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript — production-grade stack
  • Fixed-price contracts — no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Mapped Types

Mapped types transform the shape of existing types — creating new types by iterating over the keys of another type.

// The built-in Partial, Required, Readonly are all mapped types:
type Partial<T> = { [K in keyof T]?: T[K] };
type Required<T> = { [K in keyof T]-?: T[K] };  // -? removes optionality
type Readonly<T> = { readonly [K in keyof T]: T[K] };

// Custom: make specific keys optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

interface CreateUserInput {
  name: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';
  avatarUrl: string;
}

// role and avatarUrl become optional — rest remain required
type CreateUserInputOptional = PartialBy<CreateUserInput, 'role' | 'avatarUrl'>;

// Custom: transform all values to a different type
type Nullable<T> = { [K in keyof T]: T[K] | null };
type Stringify<T> = { [K in keyof T]: string };

// Remapping keys with 'as' clause (TypeScript 4.1+)
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface User { id: string; name: string; email: string; }
type UserGetters = Getters<User>;
// { getId: () => string; getName: () => string; getEmail: () => string; }

Template Literal Types

Template literals in types enable string manipulation at the type level.

// Event name unions
type EventPrefix = 'user' | 'order' | 'payment';
type EventAction = 'created' | 'updated' | 'deleted';
type EventName = `${EventPrefix}.${EventAction}`;
// 'user.created' | 'user.updated' | 'user.deleted' | 'order.created' | ...

// Type-safe event emitter
type EventMap = {
  'user.created': { userId: string; email: string };
  'user.deleted': { userId: string };
  'order.created': { orderId: string; total: number };
  'order.paid': { orderId: string; paymentIntentId: string };
};

class TypedEventEmitter {
  private listeners = new Map<string, Function[]>();

  on<K extends keyof EventMap>(
    event: K,
    listener: (data: EventMap[K]) => void
  ): void {
    const existing = this.listeners.get(event) ?? [];
    this.listeners.set(event, [...existing, listener]);
  }

  emit<K extends keyof EventMap>(event: K, data: EventMap[K]): void {
    this.listeners.get(event)?.forEach(listener => listener(data));
  }
}

const emitter = new TypedEventEmitter();
emitter.on('user.created', ({ userId, email }) => {  // Fully typed params
  console.log(userId, email);
});
// emitter.emit('user.created', { orderId: '...' });  // Error! Wrong shape

🚀 Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Branded Types

Branded types prevent accidentally passing one primitive type where another is expected — catching logic bugs at compile time that TypeScript would otherwise allow.

// Without branding — all strings are interchangeable
function sendEmail(to: string, from: string) { /* ... */ }
sendEmail(fromAddress, toAddress);  // Swapped args — TypeScript doesn't catch this!

// With branded types
declare const __brand: unique symbol;
type Brand<B, T> = T & { [__brand]: B };

type UserId = Brand<'UserId', string>;
type Email = Brand<'Email', string>;
type OrderId = Brand<'OrderId', string>;

// Constructor functions validate and brand
function createUserId(raw: string): UserId {
  if (!raw.match(/^[0-9a-f-]{36}$/)) throw new Error('Invalid UUID');
  return raw as UserId;
}

function createEmail(raw: string): Email {
  if (!raw.includes('@')) throw new Error('Invalid email');
  return raw.toLowerCase() as Email;
}

function sendEmail(to: Email, from: Email) { /* ... */ }

const email = createEmail('alice@example.com');
const userId = createUserId('550e8400-e29b-41d4-a716-446655440000');

sendEmail(email, email);  // ✅
// sendEmail(userId, email);  // Error: UserId is not assignable to Email ✅
// sendEmail('raw-string', email);  // Error: string is not Email ✅

Branded types for monetary values (prevent cents/dollars confusion):

type Cents = Brand<'Cents', number>;
type Dollars = Brand<'Dollars', number>;

function toCents(dollars: Dollars): Cents {
  return Math.round(dollars * 100) as Cents;
}

function formatPrice(price: Cents): string {
  return `$${(price / 100).toFixed(2)}`;
}

// API returns prices in cents
const priceInCents = 1999 as Cents;  // $19.99
formatPrice(priceInCents);  // ✅
// formatPrice(19.99 as Dollars);  // Error: Dollars is not Cents ✅

Type-Safe API Client

Combining generics, conditional types, and template literals to build a fully typed API client:

// types/api.ts
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

export interface ApiRoute<
  TPath extends string = string,
  TMethod extends HttpMethod = HttpMethod,
  TBody = never,
  TResponse = unknown,
  TQuery = never,
> {
  path: TPath;
  method: TMethod;
  body: TBody;
  response: TResponse;
  query: TQuery;
}

// Define all routes in one place
export type ApiRoutes = {
  'GET /users': ApiRoute<'/users', 'GET', never, User[], { page?: number; limit?: number }>;
  'GET /users/:id': ApiRoute<'/users/:id', 'GET', never, User, never>;
  'POST /users': ApiRoute<'/users', 'POST', CreateUserInput, User, never>;
  'PUT /users/:id': ApiRoute<'/users/:id', 'PUT', UpdateUserInput, User, never>;
  'DELETE /users/:id': ApiRoute<'/users/:id', 'DELETE', never, void, never>;
  'GET /orders': ApiRoute<'/orders', 'GET', never, Order[], { status?: OrderStatus }>;
  'POST /orders': ApiRoute<'/orders', 'POST', CreateOrderInput, Order, never>;
};

type RouteKey = keyof ApiRoutes;

// Extract types from route key
type RouteBody<K extends RouteKey> = ApiRoutes[K]['body'];
type RouteResponse<K extends RouteKey> = ApiRoutes[K]['response'];
type RouteQuery<K extends RouteKey> = ApiRoutes[K]['query'];

// Type-safe client
class ApiClient {
  constructor(private baseUrl: string, private token?: string) {}

  async request<K extends RouteKey>(
    route: K,
    options: {
      params?: Record<string, string>;
      body?: RouteBody<K> extends never ? never : RouteBody<K>;
      query?: RouteQuery<K> extends never ? never : RouteQuery<K>;
    } = {}
  ): Promise<RouteResponse<K>> {
    const [method, pathTemplate] = (route as string).split(' ');
    
    let path = pathTemplate;
    if (options.params) {
      Object.entries(options.params).forEach(([k, v]) => {
        path = path.replace(`:${k}`, v);
      });
    }

    const url = new URL(path, this.baseUrl);
    if (options.query) {
      Object.entries(options.query as Record<string, string>).forEach(([k, v]) => {
        if (v !== undefined) url.searchParams.set(k, String(v));
      });
    }

    const res = await fetch(url.toString(), {
      method,
      headers: {
        'Content-Type': 'application/json',
        ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
      },
      body: options.body ? JSON.stringify(options.body) : undefined,
    });

    if (!res.ok) {
      const error = await res.json().catch(() => ({ message: res.statusText }));
      throw new Error(error.message ?? `HTTP ${res.status}`);
    }

    return res.json() as Promise<RouteResponse<K>>;
  }
}

// Usage — fully typed, no casting
const api = new ApiClient('https://api.example.com', token);

const users = await api.request('GET /users', { query: { page: 1, limit: 20 } });
// users: User[] ✅

const user = await api.request('GET /users/:id', { params: { id: '123' } });
// user: User ✅

const newUser = await api.request('POST /users', {
  body: { name: 'Alice', email: 'alice@example.com' },
});
// newUser: User ✅

Utility Types Reference

Built-inWhat It DoesExample
Partial<T>All fields optionalPartial<User>
Required<T>All fields requiredRequired<CreateInput>
Readonly<T>All fields readonlyReadonly<Config>
Pick<T, K>Keep only K keysPick<User, 'id' | 'name'>
Omit<T, K>Remove K keysOmit<User, 'passwordHash'>
Record<K, V>Object with K keys, V valuesRecord<string, number>
Exclude<T, U>Remove U from union TExclude<string | null, null>
Extract<T, U>Keep U in union TExtract<string | number, string>
NonNullable<T>Remove null/undefinedNonNullable<string | null>
ReturnType<F>Return type of functionReturnType<typeof getUser>
Parameters<F>Params tuple of functionParameters<typeof createUser>
Awaited<T>Unwrap PromiseAwaited<ReturnType<typeof fetch>>

Working With Viprasol

We build TypeScript-first backends and frontends where type safety is a feature of the system, not just a linting tool. Our shared type libraries — API contracts, event schemas, domain models — eliminate entire categories of runtime errors before code is deployed.

Talk to our TypeScript team about your codebase.


See Also

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms — we build it all with Next.js and React.

Free consultation • No commitment • Response within 24 hours

Viprasol · Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.