TypeScript Advanced Types: Generics, Conditional Types & Mapped Types for Backend Engineers

TypeScript's type system is one of the most expressive in any mainstream language. Yet most developers barely scratch the surface — they use string, number, and maybe interface, then reach for any whenever things get complicated. This guide dives into the type constructs that make TypeScript genuinely powerful for backend and Angular engineers: generics with constraints, conditional types, mapped types, the infer keyword, and discriminated unions.

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · TypeScript · Angular · Spring Boot

TypeScript April 4, 2026 16 min read TypeScript Mastery Series
TypeScript advanced types generics and conditional types

Why Advanced Types Matter for Backend Engineers

TypeScript Advanced Types Architecture | mdsanwarhossain.me
TypeScript Advanced Types — mdsanwarhossain.me

TypeScript is the de-facto language for Angular, and it is rapidly becoming the preferred choice for Node.js backend development. When you build NestJS services, Express APIs, or Angular frontends that talk to Spring Boot backends, you are writing TypeScript every day. Advanced types serve three concrete purposes:

  1. Eliminate runtime errors at compile time. A well-typed generic repository makes it impossible to accidentally store an Order in a UserRepository.
  2. Document intent automatically. A function typed with conditional return types documents its own behaviour more clearly than any JSDoc comment.
  3. Enable safe refactoring. Mapped types let you derive new types from existing ones so that adding a field to a model automatically propagates type-safe changes everywhere that model is used.

TypeScript searches receive over 25,000 queries per month from Angular and Node.js developers. The questions cluster around three topics: generics constraints, conditional types, and mapped types. This guide answers all three in depth.

Generics: Beyond Simple Type Parameters

Every TypeScript developer knows basic generics: Array<T>, Promise<T>. The real power starts when you add constraints and multiple type parameters.

Generic Constraints with extends

// Without constraint — too broad, no useful methods on T
function logId<T>(item: T): void {
  console.log(item.id); // Error: Property 'id' does not exist on type 'T'
}

// With constraint — T must have at least an 'id' property
interface HasId {
  id: string | number;
}

function logId<T extends HasId>(item: T): void {
  console.log(item.id); // OK — TypeScript knows 'id' exists
}

logId({ id: 42, name: 'Order' });       // works
logId({ name: 'Missing id' });          // compile error

Generic Repository Pattern

This is the most common use of generics in production backend code. Rather than writing separate repository classes for every entity, you write one generic base:

interface Entity {
  id: string;
  createdAt: Date;
  updatedAt: Date;
}

interface Repository<T extends Entity> {
  findById(id: string): Promise<T | null>;
  findAll(filter?: Partial<T>): Promise<T[]>;
  save(entity: T): Promise<T>;
  delete(id: string): Promise<void>;
}

// Concrete implementation — fully type-safe, no 'any'
class TypeOrmRepository<T extends Entity> implements Repository<T> {
  constructor(private readonly entityClass: new () => T) {}

  async findById(id: string): Promise<T | null> {
    // TypeORM call — T is inferred at construction time
    return AppDataSource.getRepository(this.entityClass).findOneBy({ id } as any);
  }

  async save(entity: T): Promise<T> {
    return AppDataSource.getRepository(this.entityClass).save(entity);
  }
  // ...
}

// Usage — completely type-safe
const orderRepo = new TypeOrmRepository(Order);
const order = await orderRepo.findById('uuid-123'); // type: Order | null

Multiple Type Parameters and Default Types

// ApiResponse with separate data and error type parameters
interface ApiResponse<TData, TError = string> {
  data: TData | null;
  error: TError | null;
  status: number;
  timestamp: string;
}

// Default type parameter — TError defaults to string
async function fetchUser(id: string): Promise<ApiResponse<User>> {
  try {
    const user = await userService.findById(id);
    return { data: user, error: null, status: 200, timestamp: new Date().toISOString() };
  } catch (e) {
    return { data: null, error: (e as Error).message, status: 500, timestamp: new Date().toISOString() };
  }
}

// Override the default error type when needed
async function fetchOrder(id: string): Promise<ApiResponse<Order, AppError>> {
  // ...
}

Conditional Types: Type-Level if/else

TypeScript Type System Patterns | mdsanwarhossain.me
TypeScript Type System Patterns — mdsanwarhossain.me

Conditional types have the form T extends U ? X : Y. They are evaluated at the type level, not at runtime. This lets you write type transformations that branch based on the shape of input types.

Basic Conditional Types

// Is T an array? Extract the element type, otherwise keep T.
type Unwrap<T> = T extends Array<infer E> ? E : T;

type A = Unwrap<string[]>;    // string
type B = Unwrap<number>;     // number (not an array, so unchanged)
type C = Unwrap<User[]>;     // User

// IsPromise check
type IsPromise<T> = T extends Promise<any> ? true : false;

type D = IsPromise<Promise<string>>;  // true
type E = IsPromise<string>;           // false

Distributive Conditional Types

When the type parameter is a union, TypeScript automatically distributes the conditional over each member — a feature that is often surprising but extremely useful:

type NonNullable<T> = T extends null | undefined ? never : T;

// Distributed over the union:
type F = NonNullable<string | null | undefined>;
// = (string extends null|undefined ? never : string) |
//   (null   extends null|undefined ? never : null)   |
//   (undefined extends null|undefined ? never : undefined)
// = string | never | never
// = string

// Practical usage: filter union members
type OnlyStrings<T> = T extends string ? T : never;
type G = OnlyStrings<string | number | boolean>;  // string

Built-in Conditional Utility Types

// Extract the return type of any function
type ReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : any;

async function createOrder(dto: CreateOrderDto): Promise<Order> { /* ... */ }

type OrderResult = ReturnType<typeof createOrder>;  // Promise<Order>
type AwaitedOrder = Awaited<OrderResult>;           // Order (TypeScript 4.5+)

// Extract parameter types
type Parameters<T extends (...args: any) => any> =
  T extends (...args: infer P) => any ? P : never;

type CreateOrderParams = Parameters<typeof createOrder>;
// [dto: CreateOrderDto]

The infer Keyword: Extracting Nested Types

The infer keyword is only valid inside conditional types and lets TypeScript infer a type variable from the structure being matched. It is the key to writing powerful type-level destructuring.

// Unwrap the value type from a Promise
type PromiseValue<T> = T extends Promise<infer V> ? V : T;

type H = PromiseValue<Promise<User[]>>;  // User[]
type I = PromiseValue<string>;           // string (not a promise)

// Unwrap nested Promises recursively
type DeepAwaited<T> = T extends Promise<infer V> ? DeepAwaited<V> : T;

type J = DeepAwaited<Promise<Promise<Order>>>;  // Order

// Extract the first element type of a tuple
type Head<T extends any[]> = T extends [infer First, ...any[]] ? First : never;

type K = Head<[string, number, boolean]>;  // string

// Extract the constructor parameters of a class
type ConstructorParams<T> = T extends new (...args: infer P) => any ? P : never;

class UserService {
  constructor(private readonly repo: UserRepository, private readonly cache: CacheService) {}
}

type UserServiceParams = ConstructorParams<typeof UserService>;
// [repo: UserRepository, cache: CacheService]

Mapped Types: Transforming Existing Types

Mapped types iterate over the keys of an existing type and produce a new type. They are the mechanism behind nearly every utility type in TypeScript's standard library.

Basic Mapped Type Syntax

// The generic form of Readonly
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

// The generic form of Partial
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// The generic form of Required
type MyRequired<T> = {
  [K in keyof T]-?: T[K];  // -? removes optionality
};

interface User {
  id: string;
  name: string;
  email?: string;
}

type PartialUser = MyPartial<User>;
// { id?: string; name?: string; email?: string }

type RequiredUser = MyRequired<User>;
// { id: string; name: string; email: string }  (email is now required)

Key Remapping with as

TypeScript 4.1 introduced key remapping, letting you transform key names as part of the mapping:

// Convert all keys to their getter names
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

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

type UserGetters = Getters<UserModel>;
// {
//   getName: () => string;
//   getEmail: () => string;
//   getAge: () => number;
// }

// Filter out keys that have a specific value type
type StringKeys<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type L = StringKeys<{ id: string; count: number; label: string }>;
// { id: string; label: string }  — count is filtered out

Combining Mapped and Conditional Types

// Make all Date fields optional, keep everything else required
type OptionalDates<T> = {
  [K in keyof T]: T[K] extends Date ? T[K] | undefined : T[K];
};

// Deep Readonly — recursively makes all nested objects readonly
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

// Deep Partial — recursively makes all nested objects partial
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

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

type PartialConfig = DeepPartial<Config>;
// All nested fields become optional — useful for merge/patch operations

Template Literal Types

Introduced in TypeScript 4.1, template literal types let you construct string types using template literal syntax. They are invaluable for typed event systems, route definitions, and API clients.

// Typed event names — only valid on${Capitalized} patterns
type EventName<T extends string> = `on${Capitalize<T>}`;

type ClickEvent = EventName<'click'>;   // 'onClick'
type ChangeEvent = EventName<'change'>; // 'onChange'

// Typed REST route builder
type ApiRoute = `/api/v${number}/${string}`;

function fetch<T>(route: ApiRoute): Promise<T> { /* ... */ }

fetch('/api/v1/users');         // OK
fetch('/api/v2/orders/123');    // OK
fetch('/users');                // Error — does not match ApiRoute pattern

// DOM event handler map from union
type DomEvents = 'click' | 'focus' | 'blur' | 'change';
type EventHandlers = {
  [E in DomEvents as `on${Capitalize<E>}`]: (event: Event) => void;
};
// { onClick: ...; onFocus: ...; onBlur: ...; onChange: ... }

// CRUD operation types from model names
type Model = 'User' | 'Order' | 'Product';
type Operation = 'create' | 'update' | 'delete';
type ApiPermission = `${Operation}${Model}`;
// 'createUser' | 'updateUser' | 'deleteUser' | 'createOrder' | ...
// 9 string literals generated automatically

Discriminated Unions and Exhaustiveness Checking

Discriminated unions are one of TypeScript's most practical features. They model domain events, API responses, and state machines in a way that the compiler guarantees you have handled every case.

// Shape of a result type — common in functional TypeScript
type Result<TValue, TError = Error> =
  | { ok: true; value: TValue }
  | { ok: false; error: TError };

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) return { ok: false, error: 'Division by zero' };
  return { ok: true, value: a / b };
}

const result = divide(10, 2);
if (result.ok) {
  console.log(result.value); // TypeScript knows this is 'number'
} else {
  console.error(result.error); // TypeScript knows this is 'string'
}

// Domain events with discriminated union
type OrderEvent =
  | { type: 'ORDER_CREATED'; orderId: string; customerId: string; total: number }
  | { type: 'ORDER_SHIPPED'; orderId: string; trackingNumber: string }
  | { type: 'ORDER_CANCELLED'; orderId: string; reason: string; refundAmount: number };

function handleOrderEvent(event: OrderEvent): void {
  switch (event.type) {
    case 'ORDER_CREATED':
      // TypeScript narrows: event has orderId, customerId, total
      sendOrderConfirmation(event.customerId, event.orderId);
      break;
    case 'ORDER_SHIPPED':
      // TypeScript narrows: event has trackingNumber
      sendShippingNotification(event.orderId, event.trackingNumber);
      break;
    case 'ORDER_CANCELLED':
      // TypeScript narrows: event has reason, refundAmount
      processRefund(event.orderId, event.refundAmount);
      break;
    default:
      // Exhaustiveness check — if you add a new event type without handling it,
      // TypeScript flags this line as an error
      const _exhaustive: never = event;
      throw new Error(`Unhandled event type: ${JSON.stringify(_exhaustive)}`);
  }
}

Real-World Pattern: Type-Safe API Client

Combining generics, conditional types, and mapped types, you can build a fully type-safe API client where the return type is automatically inferred from the route definition:

// Route definition map — declare once, use everywhere
interface ApiRoutes {
  'GET /api/v1/users': { params: never; response: User[] };
  'GET /api/v1/users/:id': { params: { id: string }; response: User };
  'POST /api/v1/orders': { params: CreateOrderDto; response: Order };
  'DELETE /api/v1/orders/:id': { params: { id: string }; response: void };
}

type RouteKey = keyof ApiRoutes;
type RouteParams<K extends RouteKey> = ApiRoutes[K]['params'];
type RouteResponse<K extends RouteKey> = ApiRoutes[K]['response'];

// Type-safe client — response type is inferred from the route key
async function apiCall<K extends RouteKey>(
  route: K,
  params: RouteParams<K>
): Promise<RouteResponse<K>> {
  const [method, path] = (route as string).split(' ');
  const response = await fetch(buildUrl(path, params as any), { method });
  return response.json();
}

// Usage — fully typed, no manual type annotations needed
const users = await apiCall('GET /api/v1/users', undefined as never);
// users: User[]

const order = await apiCall('POST /api/v1/orders', { customerId: 'c1', items: [] });
// order: Order

Key TypeScript 5.x Improvements

TypeScript 5.x introduced several improvements relevant to advanced types:

Interview Cheat Sheet

Concept Syntax Use Case
Generic constraint<T extends HasId>Repository / service base classes
Conditional typeT extends U ? X : YType-level branching logic
infer keywordT extends Promise<infer R>Extract nested types
Mapped type{ [K in keyof T]: T[K] }Transform all keys of a type
Key remapping[K in keyof T as ...]Rename or filter mapped keys
Template literal type`on${Capitalize<T>}`Typed event systems, routes
Discriminated union{ type: 'A' } | { type: 'B' }Domain events, state machines
Exhaustiveness checkconst x: never = valEnsure all union cases handled

Conclusion

TypeScript's advanced type system — generics with constraints, conditional types, the infer keyword, mapped types, and template literal types — gives you the tools to express complex domain constraints at the type level rather than at runtime. The payoff is enormous: a well-typed codebase catches entire categories of bugs before the code ships, refactors safely, and documents itself through its types.

For Angular engineers building frontends that talk to Spring Boot APIs, or for Node.js developers writing NestJS backend services, these are not exotic academic features — they are everyday tools that the best engineers use to write more reliable code. For more Spring Boot backend patterns that complement this TypeScript knowledge, see the JWT security guide and the Angular Signals guide.

Leave a Comment

Related Posts

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · TypeScript · Angular · Spring Boot

Last updated: April 4, 2026