1. The Process Type for Managing State Transitions

Have you ever caught yourself writing code like this?

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

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

If that pattern feels familiar, you're only setting yourself up for pain further down the line. Before long, your code starts to look like this (and that's merely scratching the surface) — I go into greater depth on this topic in my piece Exhaustiveness Checking And Discriminant Property: The Complete Guide.

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

The solution here is the discriminant property, which relies on Discriminated Unions.

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

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

This approach gives us an immediate way to identify which "variant" of the state we're working with. For the ok variant, we have access to the data field; for the "fail" variant, we can reach the error field. The other two variants offer no such access. The result is far simpler code, with no extra "nulls" cluttering the logic.

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

Yet, reproducing this structure at every turn leads to considerable repetition, and there are countless scenarios like "fetch, display, and handle errors" or "save and show a message." A custom Process<TData, TError> type can step in to solve 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' };

Observe that we can also drop the 'idle' status altogether via TSkipIdle. In some flows, the sequence is just busy -> ok -> fail, since an "idle" state isn't necessary for a "get" operation. Adding it would just produce extra boilerplate and bump up cyclomatic complexity — the application has no real need for that status in such cases. If data loads the moment a component mounts, there's no point calling an additional state update merely to transition from idle to busy.

Here's a full example that shows off what it can do.

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" };
  }
};

Compare that with 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 come out leaner, with lower cyclomatic complexity. We're dealing with four possible states instead of 2 (loading) × 2 (error) × 2 (data) = 8 combinations. There's also no chance of accidentally corrupting the state—say, leaving the loading flag stuck at false and getting an endless spinner. A bit tidier, wouldn't you say?

This is merely one example of where this utility type shines. A thorough breakdown of the pitfalls you might hit when using flag-based state variants is available in the Exhaustiveness Checking and Discriminant Property: The Complete Guide article.

2. Modeling API Communication with Result

When you work with fetch or axios, a rejected promise without a .catch() block leads to an unhandled rejection. On top of that, some APIs return a different data shape for errors rather than rejecting the promise, which makes handling even more awkward.

That behavior is completely legitimate, but what if there were a cleaner way? Imagine having a type that captures this all-too-common scenario more elegantly—something like: { status: "aborted" } | { status: "fail" } | { status: "ok" }. It might 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 deliberately left out the implementation of service, since it varies depending on the project. Sometimes it's more "generic," other times it's tightly coupled to the domain. This piece is about types, not runtime logic, so building that service out is a worthwhile exercise for you to try.

This way of modeling API responses is particularly handy in React. You can easily cancel work inside useEffect and automatically return void 0 to skip set-state calls or any other side effects after the hook has unmounted or a dependency in the array shifts.

3. Fighting Primitive Obsession with Brand

Primitive obsession is a code smell where developers reach for basic primitives like string or number to stand in for richer, domain-specific concepts. It's an overly loose approach that invites subtle bugs, like the one here:

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);

The fix is to introduce a branded type, which prevents such invalid assignments unless you perform an explicit type cast.

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

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

Under the hood, this creates an intersection type that pairs the primitive (for instance, string) with a unique, phantom property like __brand. A plain string doesn't carry that property, so TypeScript treats it as a distinct type—all without any runtime cost, since the brand is stripped away 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. Keeping Types Tidy with Prettify

Have you ever built a complex type from TypeScript's utility types like Pick, Omit, or intersections (&), only to hover over it and see a convoluted definition pop up in your editor? Instead of a neat, flat object, TypeScript tends to show the entire formula behind the type. This technique, spread widely by Matt Pocock, addresses that exact annoyance.

Take a complex, computed type—it might appear like this in your editor's IntelliSense tooltip:

Ugly type definition image

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

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

This utility works by going through every property of the input object (TObject) and explicitly projecting them into a fresh object shape. The trailing & {} is a clever trick that nudges TypeScript to evaluate the new structure and present the flattened, final object type instead of the underlying, complicated one.

Now, after applying Prettify, you end up with a far more presentable type definition:

Formatted type definition
Don't go sprinkling it everywhere, though. Save it for cases where the types genuinely get unwieldy :D.

5. Safe Route Typing with StrictURL

Building URLs by hand with string concatenation, like '/users/' + userId, is brittle and invites runtime headaches. A StrictURL utility type lets us enforce a proper URL structure at compile time, leaning on some of TypeScript's advanced features to deliver 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>}`}`;

What Lies Behind the Magic

On the surface, this utility looks intimidating, but its power comes from two ideas working in concert:

  1. The infer Keyword: This is the heart of the whole mechanism. infer lets you declare a type variable inside a conditional check. In [infer Head, ...infer Tail], TypeScript pulls the first element's type into a new variable named Head and the rest of the tuple into Tail. Think of it as destructuring, but for types.
  2. Recursive Conditional Types: The type invokes itself on the Tail of the tuple. This sets up a loop that walks through each segment of the path or query string one at a time. When the tuple runs out (the "base case"), it returns an empty string, and everything is joined together into the final string literal type.

In essence, ToPath recursively strips the Head off the tuple, appends a slash when more remains, and then processes the Tail until nothing is left. ToQueryString follows the same pattern but shapes the output for URL parameters.

Seeing It in Practice

This hands TypeScript the ability to compute the final URL structure, which translates into impressive autocompletion and error catching.

// 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}`

Modern frameworks like Next.js lean on this very pattern to offer type-safe routing right out of the box, wiping out a whole class of bugs.

Wrapping Up

I've got plenty more of these gems in reserve for future pieces. The final one was admittedly intricate, but it fits perfectly with the industry's push toward strictness and type safety. AI tools can lend a hand in crafting such advanced utilities, and in turn, those robust types foster a stricter codebase that both human developers and AI assistants can navigate and debug more effectively.


5 TypeScript Utility Types You Can't Live Without — figure 3

5 TypeScript Utility Types You Can't Live Without — figure 4