Exploring TypeScript utility types and building custom utility types

TypeScript’s built-in utility types are a powerful part of its standard library. They let you manipulate existing types and streamline common type transformations.

Below is an in-depth look at TypeScript’s built-in utility types and how you can create your own custom ones. These utility types are a powerful part of TypeScript’s standard library—they help you manipulate existing types and streamline common type transformations. By mastering them, you can write more concise, maintainable, and type-safe code, and even tailor new utility types that match your project’s unique requirements.

What Are Utility Types?

TypeScript utility types are predefined types that transform other types in useful ways. They come bundled with TypeScript and address common type manipulation needs. For example, utility types can help you make all properties of an interface optional, readonly, or extract a subset of them to create a new type. Essentially, they’re “type functions” that derive new types from existing ones.

A Quick Example

Consider a scenario where you have an interface defining a user:

interface IUser {
  id: number;
  name: string;
  email: string;
  isActive: boolean;
}

If you want a version of this user type where all properties are optional, you could manually mark each as optional. But that’s tedious and not DRY (Don’t Repeat Yourself). Instead, TypeScript’s Partial utility type can do this for you:

type TPartialUser = Partial<IUser>;

// Equivalent to:
// type TPartialUser = {
//   id?: number;
//   name?: string;
//   email?: string;
//   isActive?: boolean;
// }

With one line, you’ve transformed IUser into a version where every property is optional.

Common Built-In Utility Types

Let’s explore some of the most commonly used utility types.

Partial

What it does: Makes all properties in a given type optional.

Example:

interface IPost {
  title: string;
  content: string;
  published: boolean;
}

type TPartialPost = Partial<IPost>;
// TPartialPost is { title?: string; content?: string; published?: boolean; }

Partial is useful when you are dealing with update operations or initialization objects where not all properties are known upfront.

Required

What it does: The opposite of Partial. It ensures all properties of a given type are required, even if they were originally optional.

Example:

interface IUserProfile {
  username?: string;
  avatarUrl?: string;
}

type TCompleteProfile = Required<IUserProfile>;
// TCompleteProfile is { username: string; avatarUrl: string; }

This is handy when you need to ensure that a particular object is “fully formed.”

Readonly

What it does: Makes all properties in a given type immutable.

Example:

interface IConfig {
  apiKey: string;
  timeout: number;
}

type TReadonlyConfig = Readonly<IConfig>;
// TReadonlyConfig is { readonly apiKey: string; readonly timeout: number; }

Use Readonly when you need to ensure the integrity of a configuration or a constant object throughout your codebase.

Pick

What it does: Creates a new type by selecting a subset of properties from an existing type.

Example:

interface IArticle {
  title: string;
  author: string;
  content: string;
  publishedAt: Date;
}

type TArticlePreview = Pick<IArticle, "title" | "author">;
// TArticlePreview is { title: string; author: string; }

Pick is great for narrowing a complex type down to the essentials required in certain functions or components.

Omit

What it does: Creates a new type by removing one or more properties from an existing type.

Example:

type TArticleWithoutContent = Omit<IArticle, "content">;
// TArticleWithoutContent is { title: string; author: string; publishedAt: Date; }

This is the inverse of Pick—perfect for hiding sensitive data or stripping out unnecessary properties.

Record

What it does: Constructs a type with a set of property keys and a specified type for each property’s value.

Example:

type TPermissions = "read" | "write" | "delete";
type TPermissionConfig = Record<string, TPermissions>;

// Example usage:
const userPermissions: TPermissionConfig = {
  "user:1": "read",
  "user:2": "write",
  "admin": "delete"
};

Record allows you to quickly model dictionaries and maps in a type-safe manner.

Exclude, Extract, NonNullable

• Exclude removes types from a union.
• Extract gets a subset of a union that matches a given type.
• NonNullable removes null and undefined from a type.

These become essential when dealing with unions and conditional types.

type TPrimitive = string | number | boolean;
type TNonString = Exclude<TPrimitive, string>; // number | boolean

type TMixedUnion = string | number | null | undefined;
type TNonNullableMixed = NonNullable<TMixedUnion>; // string | number

type TMessageUnion = "info" | "error" | "debug";
type TErrorOnly = Extract<TMessageUnion, "error" | "warn">; // "error"

Building Custom Utility Types

While the built-in utility types cover a broad range of scenarios, you may occasionally encounter situations where you need something more specific. Thanks to TypeScript’s advanced type features—like conditional types, mapped types, and type inference—you can craft your own utility types.

Example 1: Making All Properties Non-Nullable

If you find yourself in a situation where you need a type that’s exactly like another, but with no null or undefined properties, you can build a custom utility type.

type TNonNullableProps<T> = {
  [P in keyof T]: NonNullable<T[P]>;
};

// Usage:
interface IPartialUser {
  name: string | null;
  email: string | undefined;
  age?: number | null;
}

type TUserNonNullableProps = TNonNullableProps<IPartialUser>;
// { name: string; email: string; age: number; }

This custom type uses a mapped type to iterate over the properties of T and applies NonNullable to each property type.

Example 2: Transforming All Functions Into Async Variants

Imagine you have an interface that defines a set of synchronous functions, and you want a type that ensures these functions return Promise-wrapped values. Here’s how you could do it:

type TAsyncify<T> = {
  [P in keyof T]: T[P] extends (...args: infer A) => infer R
    ? (...args: A) => Promise<R>
    : T[P];
};

// Usage:
interface ISyncActions {
  load: (id: number) => string;
  save: (data: string) => boolean;
}

type TAsyncActions = TAsyncify<ISyncActions>;
// {
//   load: (id: number) => Promise<string>;
//   save: (data: string) => Promise<boolean>;
// }

This type checks if each property is a function. If so, it wraps the return type R in a Promise.

Example 3: Merging Two Types While Prioritizing One

Let’s say you have a “base” type and a “patch” type, and you want a utility that merges them together so that properties in the patch override those in the base.

type TMerge<T, U> = {
  [P in keyof (T & U)]: P extends keyof U ? U[P] : P extends keyof T ? T[P] : never;
};

// Usage:
interface IBase {
  name: string;
  value: number;
}

interface IPatch {
  value: number;
  updatedAt: Date;
}

type TMerged = TMerge<IBase, IPatch>;
// {
//   name: string;
//   value: number; // overridden by IPatch if needed
//   updatedAt: Date;
// }

This type carefully merges the property sets, giving priority to properties that exist in U over those in T.

Tips for Creating Custom Utility Types

  1. Keep it Small: Start with simple transformations. It’s easier to reason about basic utilities and later compose them into more complex ones.
  2. Leverage Existing Utilities: Sometimes you can build on top of built-in utility types rather than writing everything from scratch.
  3. Use Conditional and Mapped Types: Become familiar with keyof, in, extends, and conditional types—they form the backbone of advanced type transformations.
  4. Test Incrementally: Test each utility type step by step to ensure it behaves as expected. You can create sample interfaces and use your utility types in type declarations to see the final result in your IDE’s tooltip or compile errors.

TypeScript utility types are essential tools for refining and repurposing existing types. They save time, reduce boilerplate, and improve code quality. Beyond the built-in ones, crafting your own custom utility types lets you tailor the type system to your project’s exact needs. Whether you’re simplifying complex interfaces, enforcing strict data models, or building a type-safe API, understanding and leveraging these utilities will elevate your TypeScript coding experience.

By mastering both the built-in and custom utility types, you’ll find yourself writing more versatile, robust, and expressive type definitions—truly embracing the “TypeScript everywhere” mindset.