TypeScriptBuilding Custom Utility Types

Building Custom Utility Types

TypeScript ships with built-in utility types like Partial, Required, Readonly, and Pick, but real-world projects constantly need types that go deeper. In this guide you will build each custom utility from scratch, learning the thought process behind every design decision.

DeepReadonly

The built-in Readonly<T> only freezes the top-level properties. Nested objects remain mutable. DeepReadonly recursively walks every property and marks it readonly.

TS
// Built-in Readonly only goes one level deep
type ShallowReadonly = Readonly<{
  user: { name: string; age: number };
}>;

// user itself is readonly but user.name is still writable
const s: ShallowReadonly = { user: { name: 'Alice', age: 30 } };
// s.user = { name: 'Bob', age: 25 };  // Error
s.user.name = 'Bob';                    // No error — shallow!

// ---------- DeepReadonly ----------
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

type Config = {
  server: { host: string; port: number };
  database: { url: string; poolSize: number };
};

const cfg: DeepReadonly<Config> = {
  server: { host: 'localhost', port: 3000 },
  database: { url: 'postgres://...', poolSize: 5 },
};

// cfg.server.host = 'prod';  // Error — deeply readonly
// cfg.database = { ... };    // Error
Note
The condition T[K] extends object recurses for objects. Primitives fall through to T[K] unchanged.
DeepPartial

Partial<T> makes top-level keys optional. DeepPartial cascades that optionality through every nested object — extremely useful for patch and update payloads.

TS
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

type User = {
  id: number;
  profile: {
    name: string;
    address: {
      city: string;
      zip: string;
    };
  };
};

type UserPatch = DeepPartial<User>;
// Equivalent to:
// {
//   id?: number;
//   profile?: {
//     name?: string;
//     address?: { city?: string; zip?: string };
//   };
// }

function updateUser(id: number, patch: UserPatch) {
  console.log('Patching user', id, 'with', patch);
}

updateUser(1, { profile: { address: { city: 'Berlin' } } }); // OK
Tip
Combine DeepPartial with a deep-merge utility to build a flexible settings system where callers only supply the keys they want to override.
DeepRequired

The mirror image of DeepPartial. Useful when you have an optional config type and want to assert that, after applying defaults, every field is present.

TS
type DeepRequired<T> = {
  [K in keyof T]-?: T[K] extends object ? DeepRequired<T[K]> : T[K];
};

// The -? modifier strips the optional marker from every mapped key.

type AppOptions = {
  theme?: {
    color?: string;
    fontSize?: number;
  };
  debug?: boolean;
};

type ResolvedOptions = DeepRequired<AppOptions>;
// {
//   theme: { color: string; fontSize: number };
//   debug: boolean;
// }

function applyDefaults(opts: AppOptions): ResolvedOptions {
  return {
    theme: {
      color: opts.theme?.color ?? 'blue',
      fontSize: opts.theme?.fontSize ?? 14,
    },
    debug: opts.debug ?? false,
  } as ResolvedOptions;
}
ValueOf

ValueOf<T> extracts a union of all value types in an object — the value-side counterpart to keyof.

TS
type ValueOf<T> = T[keyof T];

const STATUS = {
  PENDING: 'pending',
  ACTIVE: 'active',
  CLOSED: 'closed',
} as const;

type Status = ValueOf<typeof STATUS>;
// 'pending' | 'active' | 'closed'

function setStatus(s: Status) {
  console.log('Status:', s);
}

setStatus('active');   // OK
// setStatus('unknown'); // Error

// Also works with numeric values
const HTTP = { OK: 200, NOT_FOUND: 404, ERROR: 500 } as const;
type HttpCode = ValueOf<typeof HTTP>; // 200 | 404 | 500
Note
keyof T gives the union of key types. Indexing T with that union gives the corresponding value types.
Flatten / UnpackArray

These two utilities unwrap one level of nesting — arrays into their element type, and Promise wrappers into their resolved type.

TS
// Unwrap one level of array
type UnpackArray<T> = T extends Array<infer Item> ? Item : T;

type A = UnpackArray<string[]>;    // string
type B = UnpackArray<number[][]>;  // number[]  (one level only)
type C = UnpackArray<string>;      // string    (not an array, passthrough)

// Recursively flatten nested arrays
type Flatten<T> = T extends Array<infer Item> ? Flatten<Item> : T;

type D = Flatten<number[][][]>;  // number
type E = Flatten<string[]>;      // string

// Real-world example: flatten API response types
type ApiResponse<T> = { data: T; error: string | null };
type FlatData<R> = R extends ApiResponse<infer D> ? D : never;

type UserList = { id: number; name: string }[];
type UserResponse = ApiResponse<UserList>;
type Users = FlatData<UserResponse>; // { id: number; name: string }[]
Nullable

A tiny but frequently useful alias that adds null (and optionally undefined) to any type.

TS
// Adds null
type Nullable<T> = T | null;

// Adds null and undefined
type Maybe<T> = T | null | undefined;

type UserRecord = { id: number; name: string };

function findUser(id: number): Nullable<UserRecord> {
  if (id < 0) return null;
  return { id, name: 'Alice' };
}

const user: Nullable<UserRecord> = findUser(99);
if (user !== null) {
  console.log(user.name); // TypeScript knows user is UserRecord here
}

// Maybe is handy for optional function arguments
function greet(name: Maybe<string>) {
  if (name == null) {   // catches both null and undefined
    return 'Hello, stranger!';
  }
  return `Hello, ${name}!`;
}
OmitNever

When you build conditional mapped types, some properties resolve to never — a dead end that clutters the resulting type. OmitNever removes them using key remapping.

TS
type OmitNever<T> = {
  [K in keyof T as T[K] extends never ? never : K]: T[K];
};

// Example: extract only string-valued properties
type StringFields<T> = OmitNever<{
  [K in keyof T]: T[K] extends string ? T[K] : never;
}>;

type Product = {
  id: number;
  name: string;
  sku: string;
  price: number;
  category: string;
};

type ProductStrings = StringFields<Product>;
// { name: string; sku: string; category: string }
// id and price resolved to never and OmitNever dropped them

// Without OmitNever the type would be cluttered:
// { id: never; name: string; sku: string; price: never; category: string }
Tip
The trick is the key remapping clause as T[K] extends never ? never : K. Mapping a key to never removes it from the output object entirely.
PickByValue

Where Pick selects properties by name, PickByValue selects them by value type.

TS
type PickByValue<T, ValueType> = OmitNever<{
  [K in keyof T]: T[K] extends ValueType ? T[K] : never;
}>;

// Counterpart: omit by value
type OmitByValue<T, ValueType> = OmitNever<{
  [K in keyof T]: T[K] extends ValueType ? never : T[K];
}>;

type Form = {
  username: string;
  age: number;
  email: string;
  score: number;
  active: boolean;
};

type StringFormFields = PickByValue<Form, string>;
// { username: string; email: string }

type NumericFormFields = PickByValue<Form, number>;
// { age: number; score: number }

type NonNumericFields = OmitByValue<Form, number>;
// { username: string; email: string; active: boolean }
XOR — Exclusive Or

TypeScript unions allow both variants simultaneously: A | B accepts an object that satisfies both A and B. XOR enforces that exactly one variant applies.

TS
// Building block: make all keys of T optional and set to never
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };

// XOR: T without U's extra keys, or U without T's extra keys
type XOR<T, U> = (T & Without<U, T>) | (U & Without<T, U>);

// Example: a button can be either a link button or an action button, not both
type LinkButton = { href: string; label: string };
type ActionButton = { onClick: () => void; label: string };

type Button = XOR<LinkButton, ActionButton>;

const link: Button = { href: '/home', label: 'Home' };       // OK
const action: Button = { onClick: () => {}, label: 'Save' }; // OK

// const both: Button = {                                      // Error
//   href: '/home',
//   onClick: () => {},
//   label: 'Both',
// };

// Real-world: API credential types
type ApiKeyAuth = { apiKey: string };
type BearerAuth  = { token: string };
type Auth = XOR<ApiKeyAuth, BearerAuth>;
Warning
XOR can produce complex error messages. Consider using discriminated unions with a type field when readability matters more than strict mutual exclusion.
Awaited — Manual vs Built-in

TypeScript 4.5 shipped a built-in Awaited<T> type. Building it manually is an excellent exercise in recursive conditional types and understanding how TypeScript models thenables.

TS
// Manual implementation (educational — use the built-in in production)
type MyAwaited<T> =
  T extends null | undefined
    ? T                                     // preserve null/undefined
    : T extends object & { then(onfulfilled: infer F, ...args: any[]): any }
      ? F extends (value: infer V, ...args: any[]) => any
        ? MyAwaited<V>                      // recurse for nested promises
        : never
      : T;                                  // not a thenable, return as-is

type P1 = MyAwaited<Promise<string>>;           // string
type P2 = MyAwaited<Promise<Promise<number>>>;  // number
type P3 = MyAwaited<string>;                    // string (passthrough)
type P4 = MyAwaited<null>;                      // null

// The built-in (TypeScript 4.5+)
type A1 = Awaited<Promise<string>>;             // string
type A2 = Awaited<Promise<Promise<number>>>;    // number

// Practical usage: extract the resolved type of any async function
async function fetchUser(): Promise<{ id: number; name: string }> {
  return { id: 1, name: 'Alice' };
}

type FetchResult = Awaited<ReturnType<typeof fetchUser>>;
// { id: number; name: string }
Success
You now have a complete toolkit of custom utility types. Combine them freely — for example, DeepReadonly<PickByValue<Config, string>> gives a deeply immutable view of just the string fields in a config object.
Quick Reference

Utility

What it does

Typical use case

DeepReadonly<T>

Recursively marks all fields readonly

Config objects, frozen state

DeepPartial<T>

Recursively makes all fields optional

Patch/update payloads

DeepRequired<T>

Recursively removes optional markers

Resolved config after defaults

ValueOf<T>

Union of all value types

Enum-like const objects

Flatten<T>

Recursively unwraps nested arrays

Normalising API responses

Nullable<T>

T | null

Values that may be absent

OmitNever<T>

Removes never-typed keys

Cleaning up mapped types

PickByValue<T, V>

Keeps only keys whose value extends V

Form field filtering

XOR<T, U>

Exactly one of T or U, not both

Mutually exclusive options

Awaited<T>

Unwraps Promise (built-in TS 4.5+)

Async return type extraction