Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Building Custom Utility Types in TypeScript

Table of contents

  • Share on X
  • Discuss on X

Related Articles

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
Utility Types Part 3: Pick and Omit
TypeScript
9m
Aug 30, 2026

Utility Types Part 3: Pick and Omit

Rather than transforming every property of a type — as Partial, Required, and Readonly do — Pick and Omit restructure a type by including or excluding specific named properties. Pick produces a type containing only the properties you name. Omit produces a type containing every property except the ones you name.

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

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • Why Build a Custom Utility Type
  • Start With a Real Model
  • The Three Building Blocks
  • Write a Focused DeepPartial
  • Transform Property Values
  • Model Safe Update Inputs
  • Avoid Clever Type Debt
  • A Practical Checklist
  • What Is Next
  • Key Takeaways
TypeScript

Building Custom Utility Types in TypeScript

September 15, 2026•6 min read
Joshua R. Lehman
Joshua R. Lehman
Author
Layered blue TypeScript type transformations flowing from an object shape to a safe derived result
Building Custom Utility Types in TypeScript

The most valuable TypeScript types are often not the ones you write first. They are the types you derive from something the application already knows: a domain model, a validated request shape, or a public API contract. A custom utility type is simply a small, named transformation that captures that derivation once and makes it hard to get wrong everywhere else.

The goal is not to build a personal type-level standard library. It is to remove duplication at meaningful boundaries. If a User model changes, an update payload, a form state, or a serializable view should change with it where that is actually the desired relationship.

Why Build a Custom Utility Type

The built-in types are a clue to the right design. Partial<T> says "keep the same shape, but make every property optional." Pick<T, K> says "keep only these fields." Neither has hidden runtime behaviour. They describe a narrow transformation with a name that tells a reader why it exists.

Your types should meet the same standard. A useful custom utility:

  • expresses one policy rather than an unrelated pile of syntax;
  • works for more than one call site or protects an important boundary;
  • is small enough that another developer can verify it at a glance; and
  • does not pretend to validate data at runtime.

Types Describe the Contract

A type can prevent your own code from constructing an invalid update object. It cannot prove that a JSON payload from the network matches that object. Pair public boundaries with runtime validation.

Start With a Real Model

Avoid inventing a utility in isolation. Start with a type that contains the problem:

type Address = {
  line1: string;
  city: string;
  postalCode: string;
};
 
type User = {
  id: string;
  email: string;
  displayName: string;
  address: Address;
  createdAt: Date;
  updatedAt: Date;
};

An account settings screen may allow a user to change displayName and one field in address. Partial<User> is too permissive: it permits id, timestamps, and an incomplete model in places where those fields must never be written. Repeating a hand-written object shape solves today's problem but silently drifts when User gains a new editable field.

That tension is the right moment for a custom transformation.

The Three Building Blocks

Nearly every practical utility in this series comes from three mechanisms.

First, a mapped type visits each key in a type:

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

Second, a conditional type selects a result according to a relationship:

type ArrayItem<T> = T extends readonly (infer Item)[] ? Item : T;

Third, recursion applies the same policy to nested structure. The crucial design choice is deciding where recursion must stop. Dates, functions, maps, sets, and branded values are not ordinary records just because JavaScript calls them objects.

Do Not Recurse Into Everything

A naive T extends object check turns Date into an object whose methods are optional. That type is technically derived, but it no longer represents a usable date.

Write a Focused DeepPartial

Here is a deliberately conservative deep partial. It recurses into arrays and plain object-like data while preserving functions and dates:

type Primitive = string | number | boolean | bigint | symbol | null | undefined;
 
type DeepPartial<T> = T extends Primitive | Date | Function
  ? T
  : T extends readonly (infer Item)[]
    ? readonly DeepPartial<Item>[]
    : T extends (infer Item)[]
      ? DeepPartial<Item>[]
      : { [K in keyof T]?: DeepPartial<T[K]> };

Now an edit can target exactly the field it intends to change:

const update: DeepPartial<User> = {
  address: {
    postalCode: "L5B 4M7",
  },
};

This is a good helper for configuration overlays, draft forms, or nested patch operations. It is not automatically the right request DTO. A database update endpoint should normally state which fields are writable instead of accepting a recursive version of its full persistence model.

Transform Property Values

Mapped types are especially effective when the keys are correct but the values need a different representation. A form serializes a Date as a string; a transport layer may represent optional values as null.

type Serialize<T> = {
  [K in keyof T]: T[K] extends Date
    ? string
    : T[K] extends readonly (infer Item)[]
      ? Serialize<Item>[]
      : T[K] extends object
        ? Serialize<T[K]>
        : T[K];
};
 
type SerializedUser = Serialize<User>;
// createdAt and updatedAt are strings; nested Address stays intact.

Name the transformation for its boundary. Serialize<T> is clearer than TransformEverything<T> because a reader can ask one precise question: "what does this application serialize?"

Model Safe Update Inputs

The strongest pattern combines built-ins with a small policy type. First identify fields the caller must not control. Then make the remaining allowed fields optional for a patch-style request:

type ImmutableUserFields = "id" | "createdAt" | "updatedAt";
 
type UpdateUserInput = Partial<Omit<User, ImmutableUserFields>>;
 
const validUpdate: UpdateUserInput = {
  displayName: "Josh",
};
 
const invalidUpdate: UpdateUserInput = {
  // @ts-expect-error id is owned by the server
  id: "user_123",
};

If address changes are partial too, make that explicit rather than changing every nested field by surprise:

type UpdateUserInput = Partial<Omit<User, ImmutableUserFields | "address">> & {
  address?: Partial<Address>;
};

That version is slightly longer than DeepPartial<Omit<User, ImmutableUserFields>>, but its policy is visible: top-level profile fields are patchable, address is patchable one level deep, and server-managed fields are excluded.

Avoid Clever Type Debt

Type-level code has a cost. It can make error messages slow and unreadable, and a deeply recursive helper can increase compiler work across a large project. Resist the instinct to turn every repeated shape into an abstraction.

1

Start with an explicit type at the boundary. If its relationship to another model remains stable, extract the shared transformation.

2

Give the utility a domain-neutral name only when it genuinely applies across domains. Otherwise, prefer UpdateUserInput over a generic helper with a vague name.

3

Add a type test for the happy path and a rejected case whenever a utility protects a public contract.

For example, a type test can document the policy without shipping any runtime code:

type Expect<T extends true> = T;
type Equal<Left, Right> =
  (<T>() => T extends Left ? 1 : 2) extends <T>() => T extends Right ? 1 : 2
    ? true
    : false;
 
type _addressCanBePartial = Expect<
  Equal<UpdateUserInput["address"], Partial<Address> | undefined>
>;

A Practical Checklist

Before adding a helper, ask:

  • What existing type is the source of truth?
  • What single policy does the transformation encode?
  • Should functions, dates, arrays, and branded values be preserved?
  • Is this a compile-time convenience or a public runtime boundary?
  • Would an explicit domain type be clearer than another generic?

If you can answer those questions clearly, a custom utility makes your system easier to change. If not, the helper is probably hiding a design decision that deserves a name at the domain level.

What Is Next

You can now construct focused utility types from mapped types, conditional types, and recursion. The next post looks at advanced utility patterns: composing transformations deliberately, filtering keys by value type, and keeping powerful helpers readable as a codebase grows.

Key Takeaways

  • Build custom utilities from a real duplication or boundary policy, not from syntax curiosity.
  • Preserve special values such as Date and functions when writing recursive helpers.
  • Combine Partial, Pick, and Omit before reaching for a highly generic transformation.
  • Treat types as compile-time contracts; validate untrusted runtime data separately.
  • Test utilities that protect important API or application boundaries.