I decided to share a collection of custom utility types that I use in my daily work. Maybe you'll find them useful, maybe not, but it's worth knowing that creating custom types really gets the job done, especially when building strongly-typed and safe code. Perhaps it will inspire you to create your own types that solve the problems you encounter every day?

Let's dive in!

1. Handling State Transitions with Process

Ever found yourself doing something like this?

type State = {
  loading: boolean;
  error: string | null;
  data: User | null;
};

const state: State = {
  loading: true,
  error: "",
  data: null,
};

If you do this, you're just hurting yourself, because later your code looks like this (and that's just the beginning) - I talk more about this in my article Exhaustiveness Checking And Discriminant Property: The Complete Guide.

if (loading && !error && data) {}

The discriminant property (using Discriminated Unions) comes to the rescue.

type State =
  | { status: "idle" }
  | { status: "busy" }
  | { status: "ok"; data: User }
  | { status: "fail"; error: string };

const state: State = { status: "idle" };

This allows us to directly and quickly determine which "variant" of the state we're dealing with. If it's the ok variant, we can access the data field, and if it's "fail", we can access the error field. For the other two, there's no such option. This greatly simplifies the code and eliminates extra "nulls".

if (state.status === "ok") {
   // accessing data is safe here!
}

However, repeating this structure everywhere generates a lot of duplication, and there are many states like "fetch, show, and handle error" or "save and show message". A custom Process<TData, TError> type can help with this.

// utility-types.ts

/**
 * Represents the state of an asynchronous process.
 * Defaults TData to void (no data) and TError to Error.
 */
type Process<TData = void, TError = Error, TSkipIdle = false> =
  | (TSkipIdle extends false ? { status: "idle" } : never)
  | { status: "busy" }
  // If TData is void, 'data' property is omitted
  | (TData extends void ? { status: "ok" } : { status: "ok"; data: TData })
  // If TError is void, 'error' property is omitted
  | (TError extends void
      ? { status: "fail" }
      : { status: "fail"; error: TError });


// Usage Examples:
const state: Process<User> = { status: "idle" };

// Success case
const successState: Process<User> = { status: 'ok', data: { id: '1', name: 'Test' } };

// Failure case with default Error type
const errorState: Process<User> = { status: 'fail', error: new Error('Failed to fetch') };

// "idle" removed because we don't need it in this specific case
const stateWithoutIdle: Process<Comment, Error, true> = { status: 'busy' };

Notice that we also have the option to remove the 'idle' status using TSkipIdle. Sometimes the flow is just busy -> ok -> fail because we don't want an "idle" status for a "get" operation. This would generate extra code and increase cyclomatic complexity, as our application simply doesn't need that status in such cases. Maybe you're loading data immediately when a component is mounted, so there's no need to call an additional state update just to switch from idle to busy.

It can be used in this way as a complete example to demonstrate its power.

type State = Process<User>

let state: State = { status: "idle" };

const getUser = async (userId: string) => {
  state = { status: "busy" };

  try {
    const user = await apiCallToUser(userId);
    state = { status: "ok", data: user };
  } catch (error) {
    state = { status: "fail", error: "Something went wrong" };
  }
};

Instead of something like this.

type State = {
  loading: boolean;
  error: string | null;
  data: User | null;
};

let state: State = {
  loading: true,
  error: "",
  data: null,
};

const getUser = async (userId: string) => {
  state = { loading: true, error: null, data: null };

  try {
    const user = await apiCallToUser(userId);
    state = { loading: false, error: null, data: user };
  } catch (error) {
    state = { loading: false, error: "Ups", data: null };
  }
};

Both the read and update logic are cleaner and less complex in terms of cyclomatic complexity. There are four possible states instead of 2 (loading) × 2 (error) × 2 (data) = 8 combinations. There's also no risk of mistakenly updating the state (e.g., forgetting to set the loading flag to false and ending up with an infinite loader). A bit cleaner, right?

As I mentioned before, this is just one use case for this utility type. A full explanation of the problems you may encounter when defining your state variants with flags is available in the Exhaustiveness Checking and Discriminant Property: The Complete Guide article.

2. Handling API Communication with Result

When working with fetch or axios, if a promise is rejected and you forget to add a .catch() block, you'll end up with an unhandled rejection. Sometimes, an API might even return a different data shape for errors instead of rejecting the promise, which complicates handling.

Of course, this is perfectly valid behavior, but what if we could make it simpler? What if we had a type that models this common scenario more elegantly? For instance: { status: "aborted" } | { status: "fail" } | { status: "ok" }. It could look like this:

type Result<TData> =
  | { status: 'aborted' }
  | { status: 'fail'; error: unknown }
  | {
      status: 'ok';
      data: TData;
    };

// It returns Result<User>
const result = await service<User>('https://api');

if (result.status === 'aborted') return;
if (result.status === 'fail') {
  alert('Oops! ' + result.error);
  return;
}
if (result.status === 'ok') {
  alert('WORKS!');
  return;
}

// This final part serves as a safeguard. It uses TypeScript's exhaustiveness
// checking to ensure that every possible status is handled. If you were to
// comment out one of the `if` blocks above, TypeScript would throw an error on
// the following line, because the `result` variable could still hold a value
// (e.g., { status: 'aborted' }) that cannot be assigned to the `never` type.
const _exhaustiveCheck: never = result;

// This trick ensures at compile time that all cases are handled.

I've omitted the implementation of service because it may look different in the project context. Sometimes it may be more "generic", and sometimes it may be more connected to the project domain. In addition, this article is about types, not runtime. So, implementing such a service may be a good exercise for you.

Especially in React, this approach to modeling API responses is very helpful. You can easily abort stuff in useEffect, and automatically return void 0 to avoid calling set-state operations or anything else when the hook is unmounted or a dependency in the array has changed.

3. Avoid Primitive Obsession with Brand

Primitive obsession is a code smell where developers use simple primitive types, like string or number, to represent more complex, domain-specific concepts. This approach is often too generic and can lead to subtle bugs, like the one shown below:

const getUserPosts = (userId: string) => {
   // Logic that queries for users
}
const documentId = 'doc-xyz-123'; // also a string
// TypeScript sees no issue here, but it's a nasty bug!
getUserPosts(documentId);

We can prevent this by creating a branded type, which blocks such invalid assignments unless an explicit type cast is performed.

type Brand<TData, TLabel extends string> = TData & { __brand: TLabel };

type UserId = Brand<string, 'UserId'>;

This works by creating an intersection type that combines the primitive (e.g., string) with a unique, phantom property like __brand. A regular string lacks this property, so TypeScript considers it a different type, all without adding any runtime overhead as the brand is erased during compilation.

const getUserPosts = (userId: UserId) => {
   // ...
}
const documentId = 'doc-xyz-123'; // This is just a plain string
// Now TypeScript throws an error 💢, preventing the nasty bug.
// Error: Argument of type 'string' is not assignable to parameter of type 'UserId'.
getUserPosts(documentId);

// To create a UserId, you must explicitly cast it (ideally within a validation function):
const userId = "user-456" as UserId;
getUserPosts(userId); // OK

4. Make Your Types Beautiful with Prettify

Have you ever created a complex type using TypeScript's utility types like Pick, Omit, or intersections (&), only to hover over it and see a long, confusing definition in your editor? Instead of a clean, flat object, TypeScript often shows you the entire formula used to create the type. This technique, popularized by Matt Pocock, solves that.

For instance, a complex, computed type might look like this in your editor's IntelliSense tooltip:

Ugly type definition image

With the Prettify utility, we can make it look clean and readable.

type Prettify<TObject> = {
  [Key in keyof TObject]: TObject[Key];
} & {};

This simple utility type works by iterating over all the properties of the input object (TObject) and explicitly mapping them into a new object structure. The & {} at the end is a clever trick that forces TypeScript to evaluate this new structure and display the flattened, final object type instead of the underlying complex one.

Now, when you apply Prettify, you get a much nicer-looking type definition:

Formatted type definition
Don't spam it everywhere now. Just use it in cases where working with types is really hard :D.

5. Type Your Routes Safely with StrictURL

Manually building URLs with string concatenation ('/users/' + userId) is fragile and prone to runtime errors. We can enforce a correct URL structure at compile time using a StrictURL utility type, which leverages some of TypeScript's more advanced features to create fully type-safe URLs.

// Recursively joins path segments with a "/". Does not add a trailing slash.
type ToPath<TItems extends string[]> = TItems extends [
  infer Head extends string,
  ...infer Tail extends string[],
]
  ? `${Head}${Tail extends [] ? '' : `/${ToPath<Tail>}`}`
  : '';

// Recursively builds a query string from parameter names
type ToQueryString<TParams extends string[]> = TParams extends [
  infer Head extends string,
  ...infer Tail extends string[],
]
  ? `${Head}=${string}${Tail extends [] ? '' : '&'}${ToQueryString<Tail>}`
  : '';

// The main utility to construct the full URL type
type StrictURL<
  TProtocol extends 'https' | 'http',
  TDomain extends `${string}.${'com' | 'dev' | 'io'}`,
  TPath extends string[] = [],
  TParams extends string[] = [],
> = `${TProtocol}://${TDomain}${TPath extends []
  ? ''
  : `/${ToPath<TPath>}`}${TParams extends []
  ? ''
  : `?${ToQueryString<TParams>}`}`;

Breaking Down the Magic

This utility looks complex, but its power comes from two key concepts working together:

  1. The infer Keyword: This is the core of the trick. infer allows us to declare a variable for a type inside a conditional check. In [infer Head, ...infer Tail], TypeScript captures the type of the first array element into a new type variable Head and the type of the rest of the array into Tail. It's essentially destructuring for types.
  2. Recursive Conditional Types: The type calls itself with the Tail of the array. This creates a "loop" that processes each segment of the path or query string one by one. The loop stops when the array is empty (the "base case"), at which point it returns an empty string, and the results are combined into the final string literal type.

In short, ToPath recursively pulls the Head off the array, appends a slash if there is more to process, and processes the Tail, until nothing is left. ToQueryString does the same but formats the output for URL parameters.

Usage Examples in Action

This allows TypeScript to compute the final URL structure, giving you incredible autocompletion and error-checking.

// A route with a dynamic segment
type HomeRoute = StrictURL<'https', 'polubinski.io', ['articles', string, 'id']>;
// Hovering shows: `https://polubinski.io/articles/${string}/id`

// A route with query parameters
type SearchRoute = StrictURL<'https', 'google.com', ['search'], ['q', 'source']>;
// Hovering shows: `https://google.com/search?q=${string}&source=${string}`

This pattern is used by modern frameworks like Next.js to provide type-safe routing out of the box, preventing an entire class of bugs.

Summary

I have many more such beauties, but I'll save them for future articles. The last one was quite complex, but to be honest, it nicely fits into the strict and type-safe direction the industry is heading. AI tools can help craft these advanced utilities. In turn, these strong types create a stricter codebase, making it easier for both human developers and AI assistants to find and fix problems.



Tagged in:

Articles

Last Update: August 18, 2025