Right, so you’ve met the optional chaining operator (?.), that brilliant little piece of syntactic sugar that lets you navigate potential null or undefined without blowing up your entire application. And you’ve probably also been introduced to the idea of Result (or Either) types, a more structured and type-safe way of representing failure than just throwing errors into the void and hoping someone catches them.

You might be wondering: “Can I use these two beautiful things together?” The answer is a resounding “Yes, but… oh dear god, be careful.” It’s like using a chainsaw to make a fine wood carving. Powerful, but one wrong move and you’ve accidentally validated a null value as a successful operation. Let’s talk about how to do it right.

The core idea is to use the ?. operator for what it’s good at—navigation through potentially incomplete data—and the Result type for what it’s good at—representing the success or failure of an operation. The trick is to never let the ?. operator become your actual error handling logic. Its job is to safely probe, not to decide.

The All-Too-Common Anti-Pattern

Let’s start by looking at the wrong way to do it. This is the code that lulls you into a false sense of security before the production bugs start rolling in at 2 AM.

// A function that might fail, returning a Result
declare function getUser(id: string): Result<User, Error>;

// A function that might return an object with a nullable property
declare function getPreferences(user: User): { theme?: string };

// THE ANTI-PATTERN. DON'T DO THIS.
const theme: Result<string, Error> = getUser('123')
  .map(user => getPreferences(user).theme?.toUpperCase());

What’s the problem here? If getUser succeeds but getPreferences(user).theme is undefined, the ?. operator does its job: it returns undefined instead of throwing a TypeError. Then, our .map() happily packages that undefined up into a Result<string, Error> and marks it as a success. You’ve now silently converted a failure case (no theme found) into a successful one, and the type system can’t help you because Result<string, Error> successfully contains a string | undefined. You’ve defeated the entire purpose of using a Result type.

The Right Way: Explicit Branching

The correct mindset is to use the ?. operator to safely access values within a branch that you already know is a success. The Result type forces you to handle the failure case first; then you can worry about drilling into the successful value.

// Let's use a classic Result type from a library like `neverthrow` or `fp-ts`
import { Result, ok, err } from 'neverthrow';

// We have our functions that return Results
declare function getUser(id: string): Result<User, Error>;
declare function getThemeForUser(user: User): Result<string, Error>;

// This is the way.
const result = getUser('123').andThen(user => getThemeForUser(user));

// Now we handle the result explicitly
if (result.isOk()) {
  // We are in the success branch. It is safe to operate on the value.
  const themeInUppercase = result.value.toUpperCase();
  console.log(`Theme: ${themeInUppercase}`);
} else {
  // We are in the failure branch. Handle the error.
  console.error(`Failed to get theme: ${result.error.message}`);
}

Notice that ?. isn’t even here. We’re using andThen (also known as flatMap) to chain operations that can themselves fail. The type signature remains clean: Result<string, Error>.

Where ?. Actually Shines with Results

So when do you use ?.? It’s perfect for navigating within the happy path once you’ve safely established you’re on it. Let’s say the User object itself has optional properties.

// User has an optional `address` field
type User = {
  name: string;
  address?: {
    street: string;
    zipCode: string;
  };
};

declare function getUser(id: string): Result<User, Error>;

const userResult = getUser('123');

if (userResult.isOk()) {
  // We know we have a user. Now we can safely probe its optional structure.
  const zipCode = userResult.value.address?.zipCode;

  // zipCode is now string | undefined. We can handle both cases.
  if (zipCode) {
    console.log(`User's zip code is: ${zipCode}`);
  } else {
    console.log('User has no zip code on file.');
  }
} else {
  // Handle the initial error from getUser
  console.error(`Couldn't find user: ${userResult.error.message}`);
}

Here, the ?. is used correctly. It’s not masking an error; it’s handling a legitimate, non-exceptional absence of data (address is part of the domain model, not an error state) after we’ve already guaranteed we have a User object to work with.

The Pitfall of Lazy Error Construction

Another subtle trap is creating errors from values that might be null/undefined.

// 🚫 Danger! `error` could be null/undefined!
const badResult = err(new Error(someNullableValue?.toString()));

// ✅ Better: Handle the absence explicitly.
const errorMessage = someNullableValue?.toString() ?? 'Unknown error';
const goodResult = err(new Error(errorMessage));

The first line is terrible because if someNullableValue is null, you’re effectively calling new Error(undefined), which creates an Error object with the string 'undefined' as its message. That’s just noise. The second line uses the nullish coalescing operator (??) to provide a sensible default, making the error message actually useful for debugging. It’s a small thing, but it’s the difference between “oh, it failed” and “oh, it failed because of this specific reason.” Always be explicit. Your future self, staring at logs, will thank you.