Skip to content

TypeScript Patterns: Utility Types, Generics, Guards

Core Concept LearningJuly 4, 20267 min readUpdated July 21, 2026

A codebase full of any and as uses TypeScript as punctuation, not evidence. The useful patterns are the ones that preserve information through transformations and force uncertain runtime data through a real check.

For JavaScript developers comfortable with interfaces and unions, this article orders five advanced tools from derivation to runtime narrowing. You will be able to remove duplicated types, preserve generic results, and recover safely when an API payload has the wrong shape.

Apply these type-safety techniques when designing contracts with the REST API best-practices guide. For architecture context around TypeScript services, compare monoliths and microservices.

Five TypeScript patterns, from foundational to advanced
Five TypeScript patterns, from foundational to advanced

Mental map: derive, relate, then narrow

Start with the boundary between compile time and runtime. Utility types derive one static shape from another. Generics preserve relationships between inputs and outputs. Conditional and template literal types compute more precise types from those relationships. None of them validates JSON arriving over the network.

That is why type guards come last: they turn an unknown runtime value into a trusted static type only after checking it. Learn the layers in that order—derive → relate → compute → validate—and each later pattern has a job instead of becoming type-level decoration.

Quick reference

  • Prerequisite: interfaces, unions, functions, and strict mode.
  • Compile-time transforms disappear from emitted JavaScript.
  • Runtime boundaries begin as unknown, not any.
  • Use the simplest layer that expresses the invariant.

Remember this

Utility types, generics, and conditional types all disappear at runtime — only a type guard actually checks an unknown value, which is why it belongs last in the derive → relate → compute → validate order, not first.

Utility Types: Transforming Existing Types

TypeScript ships with a library of generic utility types that transform existing types without duplicating them. The most valuable ones are Partial (all fields optional), Required (all fields mandatory), Pick (subset of fields), Omit (all fields except some), Record (key-value map), and Readonly (prevent mutation). Using these instead of redefining types keeps your codebase DRY and ensures that when the base type changes, derived types update automatically.

Start from one canonical interface per domain entity, then derive request DTOs, update payloads, and API responses with Omit, Pick, and Partial. When a field is added to the base type, every derived type updates at compile time — no manual sync across files.

One source type, three derived types
One source type, three derived types

Quick reference

  • Partial<T>: all fields become optional. Use for PATCH/update request types.
  • Required<T>: all fields become mandatory. Removes optionality added by Partial.
  • Pick<T, K>: subset of T with only keys K. Use for DTOs and view models.
  • Omit<T, K>: T with keys K removed. Use for create requests (exclude id, createdAt).
  • Record<K, V>: object with keys K and values V. Safer than { [key: string]: V }.
  • Readonly<T>: all fields become read-only. Use for function parameters you must not mutate.
  • ReturnType<T>: extract the return type of a function. Useful when you don't own the function.
  • Parameters<T>: extract parameter types as a tuple. Useful for wrappers and decorators.
Manual type duplication — breaks when base type changes
1interface User {2  id: string;3  email: string;4  name: string;5  role: "admin" | "user";6  createdAt: Date;7}8 9// Manual duplication — must update in two places when User changes10interface CreateUserRequest {11  email: string;  // forgot to add 'role' when User added it12  name: string;13}14 15interface UpdateUserRequest {16  email?: string;17  name?: string;18  // forgot to sync with User fields19}
Utility types — derived automatically from source
1interface User {2  id: string;3  email: string;4  name: string;5  role: "admin" | "user";6  createdAt: Date;7}8 9// Omit server-managed fields — stays in sync with User automatically10type CreateUserRequest = Omit<User, "id" | "createdAt">;11// { email: string; name: string; role: "admin" | "user" }12 13// All fields optional for partial updates14type UpdateUserRequest = Partial<Omit<User, "id" | "createdAt">>;15// { email?: string; name?: string; role?: "admin" | "user" }16 17// Pick only what the client needs18type UserProfile = Pick<User, "id" | "name" | "email">;19 20// Record for lookup maps21const rolePermissions: Record<User["role"], string[]> = {22  admin: ["read", "write", "delete"],23  user: ["read"],24};25 26// Readonly — prevent accidental mutation27function processUser(user: Readonly<User>) {28  user.name = "changed"; // Error: Cannot assign to 'name' (read-only)29}

Remember this

Never manually duplicate type shapes. Use Omit, Pick, Partial, and Required to derive types from a single source of truth.

Generics and Generic Constraints

Generics let you write functions and classes that work with any type while preserving type safety. Without generics, you choose between being too specific (only works with User) or too permissive (accepts any, loses type information). With generics, you get both: the function works with any type the caller provides, and TypeScript remembers which type was used.

Generic constraints narrow what types are accepted. extends means the type must have at least these properties — it doesn't have to be exactly that type, just a subtype. This lets you write functions that access specific fields while remaining generic enough to work across different types.

Too loose, too narrow, or generic with a constraint
Too loose, too narrow, or generic with a constraint

Quick reference

  • T extends SomeType means T must be a subtype of SomeType — it can have more fields, not fewer.
  • keyof T extracts all keys of T as a union type: keyof User = 'id' | 'email' | 'name' | 'role' | 'createdAt'.
  • T[K] (indexed access type): the type of property K on T. User['email'] = string.
  • Default generic parameters: function create<T extends object = User>(). Fallback when caller doesn't specify.
  • Generic constraints eliminate the need for runtime type checks in many cases.
  • Avoid T extends any — defeats the purpose of generics. Be specific about what T must have.
Too permissive (any) or too specific (User only)
1// Too permissive — loses type information2function first(arr: any[]): any {3  return arr[0];4}5const name = first(["Alice", "Bob"]); // type: any — lost!6 7// Too specific — only works with User8function getById(users: User[], id: string): User | undefined {9  return users.find((u) => u.id === id);10}11// Must copy-paste for Product, Order, etc.
Generic with constraint — safe and reusable
1// Generic — preserves type, works with any array2function first<T>(arr: T[]): T | undefined {3  return arr[0];4}5const name = first(["Alice", "Bob"]); // type: string — preserved!6const num = first([1, 2, 3]);          // type: number7 8// Generic with constraint — requires id field, works with any shape that has one9function getById<T extends { id: string }>(10  items: T[],11  id: string12): T | undefined {13  return items.find((item) => item.id === id);14}15// Works with User, Product, Order — any type with an id: string field16const user = getById(users, "123");     // type: User17const product = getById(products, "456"); // type: Product18 19// Multiple constraints20function merge<T extends object, U extends object>(a: T, b: U): T & U {21  return { ...a, ...b };22}23 24// Generic API response wrapper25interface ApiResponse<T> {26  data: T;27  status: number;28  message: string;29}30async function fetchUser(id: string): Promise<ApiResponse<User>> {31  const response = await fetch(`/api/users/${id}`);32  return response.json();33}

Remember this

Write generic functions for anything you copy-paste with different types. Add constraints (extends) to access specific properties safely.

Conditional Types

Conditional types express type logic: 'if T extends X, use Y, otherwise Z.' They power TypeScript's most expressive utility types. The syntax is T extends U ? A : B — read as 'if T is assignable to U, the type is A; otherwise it's B.' Conditional types distribute over union types, making them useful for filtering and transforming unions.

The most useful built-in conditional types are NonNullable<T> (removes null and undefined), Extract<T, U> (keeps only members of T that extend U), and Exclude<T, U> (removes members of T that extend U). Understanding these helps you read library types and write your own when the built-ins aren't enough.

Quick reference

  • T extends U ? A : B — conditional type syntax. Distributes over union members automatically.
  • NonNullable<T>: removes null and undefined. Equivalent to T extends null | undefined ? never : T.
  • Extract<T, U>: keeps members of T assignable to U. For filtering union types.
  • Exclude<T, U>: removes members of T assignable to U. Opposite of Extract.
  • infer keyword: lets you extract a type from within a conditional type. Used in ReturnType, Awaited.
  • Awaited<T>: unwraps Promise types. Awaited<Promise<string>> = string.
Repetitive overloads instead of conditional types
1// Separate overloads for each case — doesn't scale2function process(input: string): string;3function process(input: number): number;4function process(input: string | number): string | number {5  return typeof input === "string" ? input.toUpperCase() : input * 2;6}7 8// Manual union filtering — repeated for each case9type StringFields = "email" | "name"; // manually maintained10type NumberFields = "age" | "score";   // manually maintained
Conditional types — derived from the type structure
1// Conditional type — return type mirrors input type2function process<T extends string | number>(input: T): T extends string ? string : number {3  if (typeof input === "string") return input.toUpperCase() as any;4  return (input as number) * 2 as any;5}6const s = process("hello"); // type: string7const n = process(42);       // type: number8 9// Extract keys of a specific type from an interface10type KeysOfType<T, V> = {11  [K in keyof T]: T[K] extends V ? K : never;12}[keyof T];13 14interface User {15  id: string;16  name: string;17  age: number;18  score: number;19  active: boolean;20}21type StringKeys = KeysOfType<User, string>;  // "id" | "name"22type NumberKeys = KeysOfType<User, number>;  // "age" | "score"23 24// NonNullable removes null and undefined25type SafeId = NonNullable<string | null | undefined>; // string26 27// Exclude removes members from a union28type Status = "pending" | "active" | "cancelled" | "refunded";29type TerminalStatus = Exclude<Status, "pending" | "active">; // "cancelled" | "refunded"30 31// Extract keeps only matching members32type SuccessStatus = Extract<Status, "active" | "completed">; // "active"

Remember this

Use conditional types when the return type depends on the input type. Built-ins (NonNullable, Exclude, Extract) cover most cases — learn them before writing custom conditional types.

Template Literal Types

Template literal types build string types from other types using the same syntax as template literal strings. They're most useful for constructing event name unions, CSS class name generators, API route types, and Redux action types — anywhere string naming conventions need to be type-checked.

Combined with mapped types, template literal types can generate entire families of types from a base set, eliminating manual string union maintenance.

Quick reference

  • Capitalize<S>, Uncapitalize<S>, Uppercase<S>, Lowercase<S>: intrinsic string manipulation types.
  • Template literal types distribute over unions — ${A | B}:${C | D} = all 4 combinations.
  • Use for event names, Redux action types, CSS class names, API route patterns.
  • Combine with mapped types (as clause) to rename keys: [K in keyof T as get${Capitalize<K>}].
  • Template literal types are compile-time only — no runtime overhead.
Stringly typed — invalid strings accepted
1// Any string is valid — typos become bugs2type UserEvent = string;3 4function on(event: UserEvent, handler: () => void) { ... }5 6on("user:created", handler);  // valid7on("user:creaded", handler);  // typo — accepted by type system, bug at runtime8on("usr:created", handler);   // typo — accepted, silent failure
Template literal types — typos caught at compile time
1// Build event name type from entity and action unions2type Entity = "user" | "order" | "product";3type Action = "created" | "updated" | "deleted";4type AppEvent = `${Entity}:${Action}`;5// "user:created" | "user:updated" | "user:deleted" |6// "order:created" | "order:updated" | "order:deleted" |7// "product:created" | "product:updated" | "product:deleted"8 9function on(event: AppEvent, handler: () => void) { ... }10 11on("user:created", handler);  // valid12on("user:creaded", handler);  // Error: not assignable to AppEvent13on("usr:created", handler);   // Error: not assignable to AppEvent14 15// Getter/setter name generation16type Getters<T> = {17  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];18};19type UserGetters = Getters<{ name: string; age: number }>;20// { getName: () => string; getAge: () => number }21 22// API route type safety23type HttpMethod = "get" | "post" | "put" | "delete" | "patch";24type Route = `/api/${string}`;25function createRoute(method: HttpMethod, path: Route) { ... }26createRoute("post", "/api/orders");   // valid27createRoute("post", "/orders");       // Error: must start with /api/

Remember this

Use template literal types for string naming conventions (event names, routes, action types). Typos in string names become compile errors instead of runtime bugs.

Type Guards and Narrowing

TypeScript narrows types within conditionals — if you check typeof x === "string", TypeScript knows x is a string in that branch. A type predicate (x is User) extends narrowing to a custom check. The predicate is a promise by your function, so every required property must actually be validated.

Failure and recovery: an API changes user.id from string to number, but an as User cast suppresses the warning. The symptom is a later crash at id.toLowerCase(); the mechanism is that casts emit no runtime check. Prevent it by parsing boundary data from unknown, detect it with a schema or guard test, and recover by returning a typed error instead of letting the malformed object enter the domain. Discriminated unions then force callers to handle that error path.

Zoom into one TypeScript discriminated-union exhaustiveness check
Zoom into one TypeScript discriminated-union exhaustiveness check

Quick reference

  • typeof checks (typeof x === 'string') narrow primitives.
  • instanceof checks (x instanceof Date) narrow class instances.
  • in operator ('email' in value) narrows objects with specific keys.
  • Type predicates (value is User) let you encode custom narrowing logic.
  • Test guards with malformed fixtures; an incomplete predicate can lie as easily as a cast.
  • Discriminated unions: add a literal status, kind, or type field to distinguish members.
  • never exhaustiveness: assign to never in default case — TypeScript errors if a case is missing.
  • Zod/Valibot: schema libraries that generate type guards automatically from schemas — preferred for API boundaries.
Unsafe casting and any — bypasses type checking
1// Casting silences the error but doesn't verify anything2function processEvent(event: unknown) {3  const e = event as UserCreatedEvent; // assumes, doesn't verify4  console.log(e.userId);               // runtime error if wrong shape5}6 7// Manual checks but TypeScript doesn't narrow8function handle(response: SuccessResponse | ErrorResponse) {9  if (response.success) {10    response.data; // Error: Property 'data' does not exist on type11  }12}
Type guards and discriminated unions
1// Type predicate — guards narrow the type2function isUser(value: unknown): value is User {3  return (4    typeof value === "object" &&5    value !== null &&6    "id" in value &&7    typeof (value as User).id === "string" &&8    "email" in value9  );10}11 12function processEvent(event: unknown) {13  if (isUser(event)) {14    event.email; // TypeScript knows: event is User here15  }16}17 18// Discriminated union — shared literal field narrows the type19type ApiResult<T> =20  | { status: "success"; data: T }21  | { status: "error"; error: string; code: number };22 23function handleResult<T>(result: ApiResult<T>) {24  if (result.status === "success") {25    result.data;  // narrowed to { status: "success"; data: T }26  } else {27    result.error; // narrowed to { status: "error"; error: string; code: number }28  }29}30 31// Exhaustiveness check — compile error if a new union member is added but not handled32type Shape = "circle" | "square" | "triangle";33 34function area(shape: Shape, size: number): number {35  switch (shape) {36    case "circle":   return Math.PI * size ** 2;37    case "square":   return size ** 2;38    case "triangle": return (Math.sqrt(3) / 4) * size ** 2;39    default:40      const _exhaustive: never = shape; // Error if shape is unhandled41      throw new Error(`Unknown shape: ${shape}`);42  }43}

Remember this

Write type predicates for runtime validation at system boundaries. Use discriminated unions for result types and error handling. Avoid as casting — it lies to TypeScript.

Key takeaway

Derive static shapes with utilities, preserve relationships with generics, compute only the rules worth encoding, and validate runtime data before narrowing it. The goal is not clever types; it is moving failures to the earliest boundary that can explain them.

Practice (25 min): create patterns.ts with a User, derive CreateUser and UpdateUser, write getById<T extends { id: string }>, and parse one unknown JSON value into { status: "success"; data: User } | { status: "error"; error: string }. Run npx tsc --strict --noEmit patterns.ts; the exercise passes when a missing role, an invalid event name, and an unhandled result variant each produce a compile error, while malformed JSON returns the typed error at runtime.

Share:

Related Articles

An Order class in a typical object-oriented codebase looks reasonable in isolation: private fields, a pay() method, a sh

Read

A knowledge base is a different problem from a marketing site's blog, even though both start as "pages made of Markdown.

Read

Connecting Large Language Models to backend databases and business logic requires strict type safety. Receiving unstruct

Read

Explore this topic

Keep learning

Follow a structured path or browse all courses to go deeper.