Skip to contentJMRG
All posts

Maybe/Option Pattern: Goodbye null and undefined

design-patternsfunctionalmaybe-optiontypescript

What is the Maybe/Option Pattern?

Maybe (also known as Option) is a container that can hold a value (Some/Just) or be empty (None/Nothing). It makes the absence of value explicit and forces its handling.

classDiagram
    class Maybe~T~ {
        <<abstract>>
        +map(fn): Maybe~U~
        +flatMap(fn): Maybe~U~
        +getOrElse(default): T
        +match(handlers): R
    }

    class Some~T~ {
        -value: T
        +map(fn): Some~U~
        +getOrElse(default): T
    }

    class None {
        +map(fn): None
        +getOrElse(default): T
    }

    Maybe <|-- Some
    Maybe <|-- None

The Problem with null/undefined

// ❌ Defensive code everywhere
function getDiscountedPrice(userId: string): number {
  const user = findUser(userId);
  if (!user) return 0;

  const membership = user.membership;
  if (!membership) return 0;

  const discount = membership.discount;
  if (!discount) return 0;

  return calculateDiscount(discount);
}

// ❌ Runtime errors
function greetUser(user: User | null) {
  console.log(user.name.toUpperCase()); // 💥 Cannot read property 'name' of null
}

Maybe Implementation

// maybe.ts
abstract class Maybe<T> {
  abstract map<U>(fn: (value: T) => U): Maybe<U>;
  abstract flatMap<U>(fn: (value: T) => Maybe<U>): Maybe<U>;
  abstract getOrElse(defaultValue: T): T;
  abstract getOrThrow(error?: Error): T;
  abstract match<R>(handlers: { some: (value: T) => R; none: () => R }): R;

  abstract isSome(): this is Some<T>;
  abstract isNone(): this is None<T>;

  // Factory methods
  static some<T>(value: T): Maybe<T> {
    return new Some(value);
  }

  static none<T>(): Maybe<T> {
    return new None();
  }

  static fromNullable<T>(value: T | null | undefined): Maybe<T> {
    return value != null ? Maybe.some(value) : Maybe.none();
  }
}

class Some<T> extends Maybe<T> {
  constructor(private readonly value: T) {
    super();
  }

  map<U>(fn: (value: T) => U): Maybe<U> {
    return Maybe.some(fn(this.value));
  }

  flatMap<U>(fn: (value: T) => Maybe<U>): Maybe<U> {
    return fn(this.value);
  }

  getOrElse(_defaultValue: T): T {
    return this.value;
  }

  getOrThrow(_error?: Error): T {
    return this.value;
  }

  match<R>(handlers: { some: (value: T) => R; none: () => R }): R {
    return handlers.some(this.value);
  }

  isSome(): this is Some<T> { return true; }
  isNone(): this is None<T> { return false; }
}

class None<T> extends Maybe<T> {
  map<U>(_fn: (value: T) => U): Maybe<U> {
    return Maybe.none();
  }

  flatMap<U>(_fn: (value: T) => Maybe<U>): Maybe<U> {
    return Maybe.none();
  }

  getOrElse(defaultValue: T): T {
    return defaultValue;
  }

  getOrThrow(error?: Error): T {
    throw error ?? new Error('Called getOrThrow on None');
  }

  match<R>(handlers: { some: (value: T) => R; none: () => R }): R {
    return handlers.none();
  }

  isSome(): this is Some<T> { return false; }
  isNone(): this is None<T> { return true; }
}

Practical Usage

Database Search

// Repository returning Maybe
class UserRepository {
  async findById(id: string): Promise<Maybe<User>> {
    const user = await prisma.user.findUnique({ where: { id } });
    return Maybe.fromNullable(user);
  }

  async findByEmail(email: string): Promise<Maybe<User>> {
    const user = await prisma.user.findUnique({ where: { email } });
    return Maybe.fromNullable(user);
  }
}

// Clean usage
const userResult = await userRepo.findById(userId);

const greeting = userResult.match({
  some: user => `Hello, ${user.name}!`,
  none: () => 'User not found'
});

Chaining with flatMap

// ✅ Elegant composition
async function getDiscountedPrice(userId: string): Promise<Maybe<number>> {
  return (await userRepo.findById(userId))
    .flatMap(user => Maybe.fromNullable(user.membership))
    .flatMap(membership => Maybe.fromNullable(membership.discount))
    .map(discount => calculateDiscount(discount));
}

// Usage
const price = await getDiscountedPrice(userId);
const finalPrice = price.getOrElse(0);

Nested Property Access

// ❌ Without Maybe - nested null checks
const street = user?.address?.street?.name ?? 'Unknown';

// ✅ With Maybe - expressive and type-safe
const street = Maybe.fromNullable(user)
  .flatMap(u => Maybe.fromNullable(u.address))
  .flatMap(a => Maybe.fromNullable(a.street))
  .map(s => s.name)
  .getOrElse('Unknown');

Combining Multiple Maybes

// Helper function to combine
function combine<A, B, R>(
  maybeA: Maybe<A>,
  maybeB: Maybe<B>,
  fn: (a: A, b: B) => R
): Maybe<R> {
  return maybeA.flatMap(a =>
    maybeB.map(b => fn(a, b))
  );
}

// Usage
const maybeFullName = combine(
  Maybe.fromNullable(firstName),
  Maybe.fromNullable(lastName),
  (first, last) => `${first} ${last}`
);

Maybe vs Optional Chaining

// Optional chaining (?.) is useful but limited
const name = user?.profile?.name ?? 'Guest';

// Maybe offers more control
const name = Maybe.fromNullable(user)
  .flatMap(u => Maybe.fromNullable(u.profile))
  .map(p => p.name)
  .match({
    some: name => name,
    none: () => {
      logger.warn('Profile not found');
      return 'Guest';
    }
  });

Comparison

Aspect null/undefined Maybe/Option
Explicit No Yes
Type-safe Partial Full
Composition Hard Easy
Runtime errors Possible Eliminated
Verbose null checks Chainable methods

When to Use

Situation Recommendation
DB searches ✅ Ideal
Optional config ✅ Ideal
Public APIs ✅ Very useful
Simple internal CRUD ⚠️ May be overkill

Conclusion

The Maybe/Option pattern transforms optional values into first-class citizens, making code safer, more expressive, and easier to reason about.