Monads: Maybe, Either, and IO in Practice

Monads have a reputation problem. Mention them in a code review and watch eyes glaze over as developers brace for category theory lectures. But here's the thing: you've probably already used monads...

Key Insights

  • Monads are containers that let you chain operations while automatically handling context like null values, errors, or side effects—think of them as programmable semicolons that decide what happens between each step.
  • Maybe and Either monads eliminate defensive null checks and try/catch blocks by making the “unhappy path” a first-class part of your data flow, resulting in cleaner, more predictable code.
  • Start with Maybe for optional values and Either for error handling—these deliver immediate practical benefits without requiring you to restructure your entire codebase.

Demystifying Monads

Monads have a reputation problem. Mention them in a code review and watch eyes glaze over as developers brace for category theory lectures. But here’s the thing: you’ve probably already used monads without knowing it. Promises in JavaScript? Monads. Optional types in Swift or Kotlin? Monads. LINQ in C#? Built on monadic principles.

At their core, monads are containers that wrap values and provide two key operations: a way to put a value in the container, and a way to chain operations that themselves return containers. The magic is in how they handle the “context” between operations—whether that’s dealing with missing values, propagating errors, or sequencing side effects.

Let’s make this concrete with three monads you can start using today.

The Maybe Monad: Eliminating Null Checks

Null references are the billion-dollar mistake, and we’ve all written code like this:

// The defensive programming nightmare
function getUserDisplayName(userId: string): string | null {
  const user = getUser(userId);
  if (user === null) return null;
  
  const profile = user.profile;
  if (profile === null) return null;
  
  const displayName = profile.displayName;
  if (displayName === null) return null;
  
  return displayName.trim();
}

Every null check is a branch point, a place where bugs hide. The Maybe monad (called Option in fp-ts and many other libraries) wraps values that might not exist and lets you chain operations that only execute when a value is present.

import { pipe } from 'fp-ts/function';
import * as O from 'fp-ts/Option';

interface Profile {
  displayName: string | null;
}

interface User {
  profile: Profile | null;
}

// Simulated lookup functions
const getUser = (userId: string): User | null => ({ profile: { displayName: '  Alice  ' } });

function getUserDisplayName(userId: string): O.Option<string> {
  return pipe(
    O.fromNullable(getUser(userId)),
    O.flatMap(user => O.fromNullable(user.profile)),
    O.flatMap(profile => O.fromNullable(profile.displayName)),
    O.map(name => name.trim())
  );
}

// Usage
const result = getUserDisplayName('123');

pipe(
  result,
  O.match(
    () => console.log('No display name found'),
    (name) => console.log(`Hello, ${name}`)
  )
);

The pipe function chains operations left-to-right. flatMap (also called chain) unwraps the Option, applies a function that returns a new Option, and re-wraps the result. If any step produces None, subsequent operations are skipped automatically.

Notice what’s missing: no null checks, no early returns, no nested conditionals. The “unhappy path” is handled by the monad itself.

The Either Monad: Typed Error Handling

Maybe tells you something went wrong but not what. Either fixes this by representing computations that can fail with a specific error. By convention, Left holds the error and Right holds the success value (mnemonic: “right” means “correct”).

import { pipe } from 'fp-ts/function';
import * as E from 'fp-ts/Either';

type ValidationError = 
  | { type: 'EMPTY_EMAIL' }
  | { type: 'INVALID_EMAIL'; value: string }
  | { type: 'PASSWORD_TOO_SHORT'; minLength: number; actual: number };

interface ValidatedUser {
  email: string;
  password: string;
}

const validateEmail = (email: string): E.Either<ValidationError, string> => {
  if (email.length === 0) {
    return E.left({ type: 'EMPTY_EMAIL' });
  }
  if (!email.includes('@')) {
    return E.left({ type: 'INVALID_EMAIL', value: email });
  }
  return E.right(email.toLowerCase());
};

const validatePassword = (password: string): E.Either<ValidationError, string> => {
  if (password.length < 8) {
    return E.left({ 
      type: 'PASSWORD_TOO_SHORT', 
      minLength: 8, 
      actual: password.length 
    });
  }
  return E.right(password);
};

const validateUser = (
  email: string, 
  password: string
): E.Either<ValidationError, ValidatedUser> => {
  return pipe(
    validateEmail(email),
    E.flatMap(validEmail => 
      pipe(
        validatePassword(password),
        E.map(validPassword => ({ 
          email: validEmail, 
          password: validPassword 
        }))
      )
    )
  );
};

Either shines when parsing external data. Here’s a more realistic example handling an API response:

import * as E from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';

type ApiError = 
  | { type: 'NETWORK_ERROR'; message: string }
  | { type: 'PARSE_ERROR'; raw: string }
  | { type: 'VALIDATION_ERROR'; field: string };

interface ApiResponse {
  data: {
    user: {
      id: number;
      name: string;
    };
  };
}

const parseJson = (raw: string): E.Either<ApiError, unknown> => {
  try {
    return E.right(JSON.parse(raw));
  } catch {
    return E.left({ type: 'PARSE_ERROR', raw });
  }
};

const validateResponse = (data: unknown): E.Either<ApiError, ApiResponse> => {
  // In production, use a library like zod or io-ts
  const obj = data as Record<string, unknown>;
  if (!obj.data || typeof obj.data !== 'object') {
    return E.left({ type: 'VALIDATION_ERROR', field: 'data' });
  }
  return E.right(data as ApiResponse);
};

const processApiResponse = (raw: string): E.Either<ApiError, string> => {
  return pipe(
    parseJson(raw),
    E.flatMap(validateResponse),
    E.map(response => response.data.user.name)
  );
};

// Error handling is explicit and typed
const result = processApiResponse('{"data": {"user": {"id": 1, "name": "Alice"}}}');

pipe(
  result,
  E.match(
    (error) => {
      switch (error.type) {
        case 'NETWORK_ERROR':
          console.error(`Network failed: ${error.message}`);
          break;
        case 'PARSE_ERROR':
          console.error(`Invalid JSON: ${error.raw.substring(0, 50)}`);
          break;
        case 'VALIDATION_ERROR':
          console.error(`Missing field: ${error.field}`);
          break;
      }
    },
    (name) => console.log(`User: ${name}`)
  )
);

The compiler enforces that you handle every error type. No more forgotten catch blocks or swallowed exceptions.

The IO Monad: Taming Side Effects

Maybe handles missing values. Either handles errors. IO handles the messiest problem of all: side effects.

Pure functions always return the same output for the same input. But reading files, making HTTP requests, or even getting the current time violate this principle. The IO monad solves this by separating the description of a side effect from its execution.

import { pipe } from 'fp-ts/function';
import * as IO from 'fp-ts/IO';
import * as fs from 'fs';

// IO<A> represents a computation that, when executed, produces an A
// Nothing runs until you call the function

const readFile = (path: string): IO.IO<string> => 
  () => fs.readFileSync(path, 'utf8');

const writeFile = (path: string, content: string): IO.IO<void> => 
  () => fs.writeFileSync(path, content);

const getCurrentTime: IO.IO<Date> = () => new Date();

const log = (message: string): IO.IO<void> => 
  () => console.log(message);

// Compose a program without executing anything
const processConfig = (inputPath: string, outputPath: string): IO.IO<void> => {
  return pipe(
    readFile(inputPath),
    IO.map(content => JSON.parse(content)),
    IO.map(config => ({ ...config, processedAt: new Date().toISOString() })),
    IO.map(config => JSON.stringify(config, null, 2)),
    IO.flatMap(output => writeFile(outputPath, output)),
    IO.flatMap(() => log('Config processed successfully'))
  );
};

// The entire program is a value we can pass around, test, or compose
const program = processConfig('./config.json', './config.processed.json');

// Only now do side effects happen
program();

This might seem like unnecessary ceremony for simple file operations. The power emerges when you need to test side-effectful code or compose complex workflows. You can mock readFile and writeFile by passing different IO implementations, no dependency injection framework required.

Composing Monads Together

Real applications don’t use just one monad. An API call might fail (Either) and return optional data (Maybe). The fp-ts library provides utilities for these situations:

import { pipe } from 'fp-ts/function';
import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option';
import * as TE from 'fp-ts/TaskEither';

type FetchError = { type: 'FETCH_ERROR'; status: number };

interface User {
  id: number;
  nickname?: string;
}

// TaskEither combines async operations with error handling
const fetchUser = (id: number): TE.TaskEither<FetchError, User> => 
  TE.tryCatch(
    async () => {
      const response = await fetch(`/api/users/${id}`);
      if (!response.ok) {
        throw { status: response.status };
      }
      return response.json() as Promise<User>;
    },
    (error): FetchError => ({ type: 'FETCH_ERROR', status: (error as {status: number}).status })
  );

// Combining Either and Option
const getUserNickname = (id: number): TE.TaskEither<FetchError, O.Option<string>> => {
  return pipe(
    fetchUser(id),
    TE.map(user => O.fromNullable(user.nickname))
  );
};

// Usage
const displayNickname = async (userId: number): Promise<void> => {
  const result = await getUserNickname(userId)();
  
  pipe(
    result,
    E.match(
      (error) => console.error(`Failed to fetch: ${error.status}`),
      (maybeNickname) => pipe(
        maybeNickname,
        O.match(
          () => console.log('User has no nickname'),
          (nickname) => console.log(`Nickname: ${nickname}`)
        )
      )
    )
  );
};

TaskEither is a monad transformer that combines Task (async operations) with Either (error handling). The fp-ts library provides similar combinations like ReaderTaskEither for dependency injection.

Practical Adoption: Libraries and Patterns

For TypeScript, your main options are:

  • fp-ts: Comprehensive, well-documented, widely used. Can feel verbose.
  • Effect: More opinionated, includes runtime features, growing ecosystem.
  • purify: Lighter weight, good for gradual adoption.

For other languages: cats and ZIO for Scala, dry-monads for Ruby, Result types built into Rust and Swift.

When should you reach for monads? They excel at:

  • Data transformation pipelines with multiple failure modes
  • Parsing and validation logic
  • Anywhere you’re writing nested null checks or try/catch blocks

When should you avoid them? Don’t introduce monads for simple CRUD operations or when your team isn’t familiar with the patterns. The learning curve is real, and “clever” code that teammates can’t maintain is worse than straightforward imperative code.

Thinking in Pipelines

Monads shift your mental model from “do this, then check if it worked, then do that” to “here’s a pipeline of transformations; the monad handles the plumbing.” This isn’t just aesthetics—it reduces bugs by making error handling and edge cases impossible to forget.

Start small. Replace one function full of null checks with Maybe. Convert one try/catch chain to Either. Once you internalize the pattern, you’ll spot opportunities everywhere.

The goal isn’t to write “functional” code for its own sake. It’s to write code where the compiler catches more bugs, where error handling is explicit and typed, and where complex data flows become readable pipelines instead of nested conditionals. Monads are a tool for that goal—use them where they help, skip them where they don’t.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.