Let’s be honest: most of the time, your functions will succeed. But the few times they don’t, you want to know why they failed, not just that they did. Relying on throwing errors is like communicating with a sledgehammer—it’s effective but lacks nuance and forces a try/catch block everywhere. Returning null or undefined is even worse; it’s a silent, data-less failure that you have to guess about.

This is where Result types (or Either types) come in. They are the civilized, type-safe way to handle operations that can fail. Instead of blowing up the execution flow, they return a container that explicitly says, “Hey, here’s the successful output, OR here’s the detailed reason it went sideways.” The compiler then forces you to handle both possibilities. It’s not a new idea—languages like Rust and Haskell have baked this in for years—but it’s a pattern we can beautifully implement with TypeScript’s union types.

The Basic Blueprint: A Discriminated Union

At its heart, a Result type is a simple discriminated union with two members: one for success (Ok), one for failure (Err).

type Result<T, E> =
  | { kind: 'ok'; value: T }
  | { kind: 'err'; error: E };

The magic is in the kind property. It’s the literal type that acts as the discriminator. TypeScript’s control flow analysis can look at this property and know, with absolute certainty, whether it’s dealing with the value or the error. This is infinitely safer than checking a property that might be undefined.

Let’s use it. Imagine a function that parses a string into a number.

function safeParseInt(s: string): Result<number, string> {
  const result = parseInt(s);
  if (isNaN(result)) {
    return { kind: 'err', error: `Could not parse "${s}" as a number.` };
  }
  return { kind: 'ok', value: result };
}

// Using it is a matter of checking the 'kind'
const parsedResult = safeParseInt("42");

if (parsedResult.kind === 'ok') {
  console.log(parsedResult.value * 2); // TypeScript knows 'value' exists here
} else {
  console.error("Error:", parsedResult.error); // TypeScript knows 'error' exists here
}

See? No try/catch. No undefined checks. Just clear, declarative code. The type system is your ally, guiding you to handle both outcomes.

Leveling Up: A More Practical Implementation

While the above works, the kind property is a bit verbose. The community has largely standardized on the property name isSuccess or a simple success boolean. Let’s create a more robust version.

type Result<T, E = Error> =
  | { success: true; data: T }
  | { success: false; error: E };

function safeReadFile(path: string): Result<string, NodeJS.ErrnoException> {
  try {
    const data = someFileSystemReadSync(path); // Imagine this exists
    return { success: true, data };
  } catch (e) {
    return { success: false, error: e as NodeJS.ErrnoException };
  }
}

const fileResult = safeReadFile('./config.json');

if (fileResult.success) {
  // Configure the app using fileResult.data
  console.log('Config loaded:', fileResult.data);
} else {
  // Handle the specific error, e.g., 'ENOENT' for file not found
  console.error('Failed to read file:', fileResult.error.message);
}

This pattern is devastatingly effective for API calls, configuration loading, or any async operation. Speaking of async…

Handling Asynchronous Results

This pattern isn’t just for synchronous code. You’ll often want a Promise that resolves to a Result, not just a value or a thrown exception. This is frequently called a Promise<Result<T, E>> or, if you’re feeling fancy, an “AsyncResult”.

type AsyncResult<T, E = Error> = Promise<Result<T, E>>;

async function fetchUser(userId: string): AsyncResult<User, string> {
  try {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) {
      // Handle HTTP errors (404, 500, etc.) gracefully
      return { success: false, error: `HTTP error! status: ${response.status}` };
    }
    const user: User = await response.json();
    return { success: true, data: user };
  } catch (e) {
    // Handle network failures or JSON parsing errors
    return { success: false, error: 'Network request failed' };
  }
}

// Usage remains just as clean
const userResult = await fetchUser('123');

if (userResult.success) {
  displayUserProfile(userResult.data);
} else {
  showErrorToast(userResult.error);
}

The Pitfall: Not Using a Library (Sometimes)

Here’s the rough edge: if you start using Result types heavily, you’ll find yourself wanting to chain operations. For example, “if this succeeds, then pass the result to this function, but if any step fails, short-circuit and return the error.” Writing this by hand gets tedious.

This is where a tiny library like neverthrow or oxide.ts can be worth its weight in gold. They provide a Result type with methods like .map(), .andThen(), and .match(), which let you compose these operations elegantly. Rolling your own full-featured implementation is a fun exercise, but for production code, leveraging a well-tested library is often the wiser, less-error-prone choice. It’s the one time I’ll advise against building it all yourself.