Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Advanced Utility Type Patterns in TypeScript

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Building Custom Utility Types in TypeScript
TypeScript
12m
Sep 15, 2026

Building Custom Utility Types in TypeScript

TypeScript's built-in utility types are useful because they transform a type you already trust. This post shows how to build the same kind of focused helpers yourself: shallow and deep partials, mutable views, nullable fields, and API-safe update inputs.

#TypeScript#Custom Utility Types+4
Utility Types Part 5: ReturnType and Parameters
TypeScript
9m
Sep 13, 2026

Utility Types Part 5: ReturnType and Parameters

ReturnType and Parameters are utility types that inspect function types rather than object types or unions. ReturnType extracts the type a function returns. Parameters extracts the types of its arguments as a tuple. Both are implemented using conditional types with infer, and both are essential for writing type-safe wrappers, decorators, and higher-order functions without duplicating type information.

#Utility Types#ReturnType+5
Utility Types Part 4: Exclude and Extract
TypeScript
9m
Sep 6, 2026

Utility Types Part 4: Exclude and Extract

Rather than operating on object types like Pick and Omit, Exclude and Extract operate on union types — filtering members in or out by assignability. Exclude removes every union member assignable to a given type. Extract keeps only those members. Together they give you precise control over union composition without restating individual members.

#Utility Types#Exclude+5
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • Utility Types Are Policy
  • Filter Keys by Their Values
  • Protect Immutable Fields
  • Compose Types in Small Steps
  • Keep Error Messages Useful
  • What Is Next
  • Key Takeaways
TypeScript

Advanced Utility Type Patterns in TypeScript

September 22, 2026•6 min read
Joshua R. Lehman
Joshua R. Lehman
Author
Centred TypeScript utility type transformations combining into a safe API contract
Advanced Utility Type Patterns in TypeScript

The best utility types are small pieces of policy. They say which fields a caller may change, which values can safely cross a boundary, or which keys a generic helper may accept. The moment a type tries to solve every possible transformation at once, it becomes difficult to trust and even harder to maintain.

This post builds on the custom utilities from the previous article by showing how to compose them without hiding the rules they encode.

Utility Types Are Policy

Suppose an application has a persistence model:

type Account = {
  id: string;
  email: string;
  displayName: string;
  isAdmin: boolean;
  createdAt: Date;
  updatedAt: Date;
};

An update endpoint should not receive Partial<Account>. That would make id, isAdmin, and timestamps look writable. The policy is more precise: a customer may update profile fields; only an administrator workflow may change privileges; the server owns identifiers and timestamps.

Encoding that policy in a named type is more useful than teaching every call site the same exclusions.

Filter Keys by Their Values

Mapped and conditional types can derive the keys whose values match a rule:

type KeysOfType<T, Value> = {
  [K in keyof T]-?: T[K] extends Value ? K : never;
}[keyof T];
 
type DateKeys = KeysOfType<Account, Date>;
// "createdAt" | "updatedAt"

The inner mapped type creates a temporary object where matching keys keep their name and non-matching keys become never. Indexing that object with [keyof T] turns its property values into a union.

This is useful when a generic function has a real runtime constraint:

function formatDateField<T>(record: T, key: KeysOfType<T, Date>) {
  return record[key].toISOString();
}

The type does not add date formatting behaviour. It prevents a caller from choosing email when the function expects a date.

Make the Runtime Assumption Visible

Filtered-key helpers are valuable when the function genuinely relies on the filtered value type. Do not use them just because the type-level trick is available.

Protect Immutable Fields

For a write contract, readable composition beats a single clever conditional:

type ServerManagedAccountFields = "id" | "isAdmin" | "createdAt" | "updatedAt";
 
type UpdateAccountInput = Partial<Omit<Account, ServerManagedAccountFields>>;

The implementation is intentionally ordinary. Omit identifies what a caller never controls, then Partial turns the remaining writable fields into a patch. If privileges later become editable through a separate administrative API, define a second explicit contract rather than quietly weakening this one.

type UpdateAccountRoleInput = Pick<Account, "isAdmin">;

Distinct input types make authorization boundaries visible in both code review and autocomplete.

Compose Types in Small Steps

The same principle applies to serialization. You may need an API shape that turns dates into strings, removes internal fields, and makes an optional nested preference object patchable. Give each step a name:

type PublicAccount = Omit<Account, "isAdmin">;
 
type SerializedAccount = Omit<PublicAccount, "createdAt" | "updatedAt"> & {
  createdAt: string;
  updatedAt: string;
};
 
type AccountPatch = Partial<Pick<Account, "displayName" | "email">>;

This is not less advanced than a deeply recursive generic. It is often more correct because every name explains a separate boundary. A future reader can change PublicAccount without needing to understand a dense conditional type that also handles dates, arrays, and arbitrary nested records.

Keep Error Messages Useful

The compiler has to evaluate your abstractions too. Highly recursive or distributive utilities can make error messages enormous and slow down editor feedback. There are three practical guardrails:

  • Prefer a domain type when the transformation belongs to one feature.
  • Stop recursive utilities at functions, dates, maps, and other special objects.
  • Add a type test for the expected and rejected use cases before the helper spreads.
const accountUpdate: UpdateAccountInput = {
  displayName: "Updated name",
};
 
const invalidAccountUpdate: UpdateAccountInput = {
  // @ts-expect-error server-managed fields are not writable here
  isAdmin: true,
};

A Type Is Not Authorization

UpdateAccountInput protects TypeScript callers. Your server still needs to authenticate the actor, validate the incoming data, and enforce authorization before updating a record.

What Is Next

You now have the type-system tools needed to derive focused contracts. The next series moves from transforming types to moving code: ES modules, import and export boundaries, and how TypeScript resolves them in real projects.

Key Takeaways

  • Treat utilities as named policies, not puzzles.
  • Filter keys only when the runtime function relies on that value constraint.
  • Compose Pick, Omit, and Partial in readable boundary types.
  • Use distinct write contracts for distinct authorization paths.
  • Protect compiler performance and error clarity by keeping helpers focused.

Review Utilities as Product Contracts

Before introducing a reusable utility, write three usage examples: the ordinary case it should make easy, a boundary case that should remain readable, and an invalid case that should fail for the right reason. The examples reveal whether the abstraction has a stable name or merely hides a one-off transformation. They also become natural type tests when the utility is exported from a shared package.

Pay particular attention to distributive conditional types. A conditional type applied to a union distributes over each member unless you prevent it by wrapping the parameter in a tuple. That behaviour is powerful for transformations such as extracting successful results from a union, but it can surprise consumers when a utility was intended to evaluate the union as a whole.

type Success<T> = T extends { ok: true; value: infer Value } ? Value : never;
type WholeUnionIsSuccess<T> = [T] extends [{ ok: true; value: unknown }]
  ? true
  : false;
 
type Values = Success<
  { ok: true; value: string } | { ok: false; error: string }
>;
// string
type AllSucceeded = WholeUnionIsSuccess<
  { ok: true; value: string } | { ok: false; error: string }
>;
// false

Name this decision in the utility documentation. A consumer who sees Success<T> can reason about member-by-member extraction; a consumer who sees a generic called Resolve<T> cannot tell whether it distributes, preserves optionality, recurses, or intentionally stops at a domain boundary.

Prefer runtime pairs for dynamic transformations

When a utility represents a real runtime operation, expose a function and type together. A pick helper can constrain keys and return Pick<T, K>; a parser can turn unknown into a validated domain type. A type-only deep transformation that has no corresponding runtime behaviour is appropriate for serialization or documentation boundaries, but it should not persuade a reviewer that values have been sanitized or authorized.

Keep compiler cost in the release budget

Type-level sophistication has a runtime-like cost: editor responsiveness and CI type-check time. Measure a representative project before publishing a recursive utility to a shared package. If errors become unreadable, introduce named intermediate aliases, cap recursion depth, or offer a domain-specific alternative. The best utility is not the cleverest transformation; it is the one a team can understand, compile, and safely evolve.

Establish an escape hatch

Not every caller benefits from the most precise possible type. Sometimes a validated boundary can return a simpler domain object, or a function can accept a named options type rather than forcing callers through a conditional generic. Document the intended extension point and resist exposing internal helper types as public API. This keeps a useful utility adaptable without making every consumer inherit its implementation complexity.