Skip to main content

TS Ep 59: Monadic Patterns (Option & Either)

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 59: This Article
Sir Tony Hoare called his invention of the null reference a “billion-dollar mistake”. In functional programming, we fix this mistake using Monadic Patterns like Option and Either.

TL;DR (Quick Summary)
#

  • The Problem: Standard JavaScript relies on null, undefined, and throw new Error(). These break the type system, crash applications at runtime, and violate the principles of Pure Functions.
  • The Option Monad: A container that holds either a value (Some) or nothing (None). It replaces null and undefined, forcing developers to explicitly handle missing data at compile time.
  • The Either Monad: A container that holds either a failure (Left) or a success (Right). It replaces throw/catch, making error handling type-safe and fully documented in the function signature.
  • The Effect Connection: The Effect type is essentially a super-powered, asynchronous Either monad with built-in dependency injection.

1. Introduction: The Billion-Dollar Mistake
#

In standard JavaScript and TypeScript, we typically indicate the absence of data by returning null or undefined.

// 🔴 The traditional (unsafe) way to handle missing data
function getUser(id: string): User | null {
  if (id === "123") return { name: "Rachmat" };
  return null;
}

const user = getUser("999");

// BOOM. Runtime Crash: Cannot read properties of null (reading 'name')
console.log(user.name);

While TypeScript’s strictNullChecks helps mitigate this, it forces you to write endless if (user !== null) checks everywhere. Worse, when you start combining multiple functions that might return null, the nested if statements become an unreadable pyramid of doom.

Even worse is how standard JavaScript handles expected errors:

// 🔴 The traditional (unsafe) way to handle errors
function divide(a: number, b: number): number {
  if (b === 0) throw new Error("Cannot divide by zero");
  return a / b;
}

// BOOM. Runtime Crash: Uncaught Error
divide(10, 0);

When you throw an error, you break the execution flow of the program. Because TypeScript does not have Checked Exceptions (unlike Java), the compiler cannot warn the caller that divide() might throw an error. The error is hidden from the function signature.


2. What is a Monad? (Simplified)
#

In the context of practical TypeScript architecture, you don’t need a PhD in Category Theory to understand a Monad.

Think of a Monad as a “Box” or a “Container” that wraps a value. Instead of operating on the value directly, you operate on the Box. The Box provides built-in rules for what happens if the value is missing, or if an error occurred.

Two of the most famous Monads are Option (sometimes called Maybe) and Either (sometimes called Result).


3. The Option Monad: Handling Missing Data
#

The Option monad is an Algebraic Data Type (which we learned about in Episode 58) that represents a value that might not exist.

It has exactly two states:

  1. Some(value): The box contains a value.
  2. None(): The box is empty.

Step-by-Step: Implementing Option
#

Let’s build a mental model by implementing a simple Option type ourselves.

// 🟢 The Option ADT (Sum Type)
type None = { readonly _tag: "None" };
type Some<A> = { readonly _tag: "Some"; readonly value: A };

type Option<A> = None | Some<A>;

// Helper functions to construct the boxes
const none = (): Option<never> => ({ _tag: "None" });
const some = <A>(value: A): Option<A> => ({ _tag: "Some", value });

// Usage
function findUserOption(id: string): Option<User> {
  if (id === "123") return some({ name: "Rachmat" });
  return none();
}

Notice the function signature: findUserOption(id: string): Option<User>. The type signature explicitly tells the caller: “I will return a Box. You must open the Box to see if the User is inside.”

const result = findUserOption("999");

// 🔴 Compiler Error: Property 'name' does not exist on type 'Option<User>'
// console.log(result.name); 

// 🟢 You MUST exhaustively check the box!
if (result._tag === "Some") {
  console.log(result.value.name);
} else {
  console.log("User not found!");
}

4. The Either Monad: Handling Expected Errors
#

While Option is great for missing data, it throws away the reason why the data is missing. If a database query fails, returning None() hides the database error.

The Either monad solves this. It represents a value that can be exactly one of two things: a Failure or a Success.

It has exactly two states:

  1. Left(error): The box contains the Error (by convention, Left is always the failure).
  2. Right(value): The box contains the Success value.

Step-by-Step: Implementing Either
#

// 🟢 The Either ADT
type Left<E> = { readonly _tag: "Left"; readonly error: E };
type Right<A> = { readonly _tag: "Right"; readonly value: A };

type Either<E, A> = Left<E> | Right<A>;

// Helper constructors
const left = <E>(error: E): Either<E, never> => ({ _tag: "Left", error });
const right = <A>(value: A): Either<never, A> => ({ _tag: "Right", value });

Now let’s rewrite our unsafe divide function using Either.

// 🟢 Pure Error Handling
function divideSafe(a: number, b: number): Either<string, number> {
  if (b === 0) return left("MathError: Cannot divide by zero");
  return right(a / b);
}

Look at the signature: Either<string, number>. The compiler now forces whoever calls divideSafe to handle the string error before they can access the number result. We have effectively created type-safe Checked Exceptions in TypeScript!


5. Throwing Errors vs Returning Either
#

Featurethrow new Error() (Impure)Either<E, A> (Pure)
Type SafetyNone. The compiler doesn’t know it throws.100% Type-safe. Errors are part of the return signature.
Control FlowHalts execution. Acts like a hidden goto.Normal function return. Pure, predictable data flow.
ExhaustivenessEasy to forget a try/catch block.Compiler forces you to unwrap the Left and Right.
PerformanceThrowing errors generates expensive stack traces.Returning an object is lightweight and incredibly fast.

6. How Option and Either Prepare Us for Effect-TS
#

In the Effect-TS ecosystem, you rarely build Option or Either from scratch. Effect provides heavily optimized, industry-standard versions of these data structures out of the box (Option.Some, Option.None, Either.Left, Either.Right).

More importantly, the core Effect type itself is essentially a massive Either.

An Effect<SuccessType, ErrorType, Context> is just an asynchronous box that will eventually resolve to either a Right(SuccessType) or a Left(ErrorType). By mastering Option and Either now, the mental leap to Effect-TS becomes trivial.


7. Troubleshooting & Common Errors
#

Error 1: Forgetting to Return
#

TS2366: Function lacks ending return statement and return type does not include 'undefined'.

The Cause: When returning left() or none() early in a function, developers accustomed to throwing errors often forget the return keyword because throw inherently halts execution. The Fix: Always return left(...).

// 🔴 Bad (Throws compiler error because it falls through)
function bad(x: number): Either<string, number> {
  if (x < 0) left("Too small"); 
  return right(x);
}

// 🟢 Good
function good(x: number): Either<string, number> {
  if (x < 0) return left("Too small");
  return right(x);
}

Error 2: Unwrapping the Box Unsafely
#

TS2339: Property 'value' does not exist on type 'Option<string>'.

The Cause: You are trying to access .value directly on the Option or Either type without checking the _tag first. The compiler doesn’t know if the value actually exists yet! The Fix: Always use a discriminated check (if (res._tag === "Some")) or use mapping functions (which we will cover in the next episode) to interact with the contents safely.


Summary & Next Steps
#

In this episode:

  • We identified how null, undefined, and throw create unsafe, unpredictable applications.
  • We defined a Monad as a functional “Box” that holds data.
  • We built the Option monad to type-safely handle missing data (Some / None).
  • We built the Either monad to type-safely handle expected errors (Left / Right).

We now have all these perfectly pure, perfectly typed functions. But if we have to write if (res._tag === ...) after every single function call, our code will be enormous.

How do we chain these functions together elegantly?

In Episode 60: Function Composition (Pipe & Flow), we will learn how to stitch pure functions together into beautiful, readable data pipelines!

typescript - This article is part of a series.
Part 59: This Article