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 <|-- NoneThe 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.