Monads

Stop playing whack-a-mole with null checks and try-catch blocks. Handle errors like a functional programming pro.

The Billion Dollar Mistake Still Haunts Us

Tony Hoare called null references his 'billion-dollar mistake.' 60 years later, we're still paying for it.

Every JavaScript Developer's Daily Nightmare

// The billion-dollar mistake in action
const user = getUser(id);
const profile = user.profile;        // 💥 Cannot read property 'profile' of null
const avatar = profile.avatar;       // 💥 Cannot read property 'avatar' of undefined
const url = avatar.url;              // 💥 Your app just crashed in production

// The defensive programming nightmare
const user = getUser(id);
if (user) {
  const profile = user.profile;
  if (profile) {
    const avatar = profile.avatar;
    if (avatar) {
      const url = avatar.url;
      if (url) {
        displayAvatar(url);
      }
    }
  }
}
// Welcome to the pyramid of doom. Again.

// The kotlinify-ts way - compose, don't nest
import { fromNullable } from 'kotlinify-ts/monads';

fromNullable(getUser(id))
  .map(user => user.profile)
  .map(profile => profile.avatar)
  .map(avatar => avatar.url)
  .fold(
    () => displayDefaultAvatar(),
    url => displayAvatar(url)
  );
// Linear. Composable. Never crashes.

The Monad Revolution

What if null checks weren't needed? What if errors handled themselves? What if your code never crashed from undefined?

Without Monads
  • • Null checks everywhere
  • • Try-catch pyramids
  • • Runtime crashes
  • • Defensive coding
  • • "Cannot read property of undefined"
With Monads
  • • Null safety guaranteed
  • • Linear error flow
  • • Compile-time safety
  • • Confident coding
  • • "It just works"

Option

The null killer. Never check for null/undefined again.

fromNullable(user?.profile?.avatar)
  .map(avatar => avatar.url)
  .getOrElse(defaultAvatar)

Either

Type-safe errors. Know exactly what can go wrong.

validateEmail(input)
  .flatMap(email => checkDomain(email))
  .fold(showError, sendWelcome)

Result

Try-catch evolved. Recover gracefully from failures.

tryCatchAsync(() => fetch(url))
  .recover(err => fetchFromCache())
  .map(data => transform(data))

Why Your Team Needs This Now

  • →50% Fewer Bugs: Most production errors are null/undefined related. Eliminate them entirely.
  • →Self-Documenting: Return types tell you exactly what errors can occur. No more guessing.
  • →Composable: Chain operations without nested if statements or try-catch blocks.
  • →Refactor Fearlessly: TypeScript ensures you handle all cases. Can't forget an error path.

Option - Handling Nullable Values

Option eliminates null/undefined checks by wrapping values that may not exist.

Basic Usage

import { Some, None, fromNullable } from 'kotlinify-ts/monads';

// Creating Options
const someValue = Some(42);
const noValue = None();
const fromNull = fromNullable(localStorage.getItem("token"));

// Transform and extract values
fromNullable(findUser(id))
  .map(user => user.email)
  .filter(email => email.includes("@"))
  .getOrElse("no-email@default.com");

// Chain operations safely
fromNullable(getUserInput())
  .flatMap(input => fromNullable(validateInput(input)))
  .map(valid => processInput(valid))
  .fold(
    () => showDefaultView(),
    result => showResultView(result)
  );

Advanced Patterns

import { Some, None, fromNullable, sequence, traverse } from 'kotlinify-ts/monads';

// Working with multiple Options
const options = [Some(1), Some(2), Some(3)];
const allValues = sequence(options); // Some([1, 2, 3])

const maybeOptions = [Some(1), None(), Some(3)];
const partial = sequence(maybeOptions); // None()

// Transform arrays to Options
const userIds = ["u1", "u2", "u3"];
const users = traverse(userIds, id => fromNullable(findUser(id)));
// Some([user1, user2, user3]) or None() if any user not found

// Combine Options with zip
const name = fromNullable(getName());
const email = fromNullable(getEmail());
const combined = name.zip(email); // Option<[string, string]>

// Use scope functions for cleaner code
fromNullable(getConfig())
  .let(config => parseConfig(config))
  .also(parsed => console.log("Config loaded:", parsed))
  .apply(config => applyConfig(config))
  .takeIf(config => config.isValid)
  .getOrElse(defaultConfig);

Try it yourself:

// Working with Option monad
const maybeToken = fromNullable(null);
console.log('Empty option:', maybeToken.getOrElse('default-token'));

const user = Some({ name: 'Alice', age: 30 });
const greeting = user
  .map(u => `Hello, ${u.name}!`)
  .getOrElse('Hello, stranger!');

console.log(greeting);

// Chain transformations
const result = Some(10)
  .map(x => x * 2)
  .filter(x => x > 15)
  .map(x => `Value: ${x}`)
  .getOrElse('Too small');

console.log(result);

Either - Success or Failure

Either represents a value with two possible types: Left (typically error) or Right (typically success).

Basic Usage

import { Left, Right } from 'kotlinify-ts/monads';

// Creating Either values
const success = Right<string, number>(42);
const failure = Left<string, number>("Error occurred");

// Validate and transform
function validateAge(input: string): Either<string, number> {
  const age = parseInt(input);
  return isNaN(age) ? Left("Invalid age") : Right(age);
}

validateAge("25")
  .map(age => age + 1)
  .flatMap(age => age >= 18 ? Right(age) : Left("Too young"))
  .fold(
    error => console.error(error),
    age => console.log("Valid age:", age)
  );

// Chain multiple operations
function parseEmail(input: string): Either<string, string> {
  return input.includes("@")
    ? Right(input.toLowerCase())
    : Left("Invalid email format");
}

function checkDomain(email: string): Either<string, string> {
  return email.endsWith("@company.com")
    ? Right(email)
    : Left("Must be company email");
}

parseEmail(userInput)
  .flatMap(checkDomain)
  .map(email => createUser(email))
  .fold(
    error => showError(error),
    user => redirectToDashboard(user)
  );

Advanced Patterns

import { Left, Right } from 'kotlinify-ts/monads';

// Transform left values (errors)
validateInput(data)
  .mapLeft(error => ({
    message: error,
    timestamp: Date.now(),
    context: "validation"
  }))
  .fold(
    enrichedError => logError(enrichedError),
    result => processResult(result)
  );

// Filter with fallback
Right<string, number>(10)
  .filterOrElse(
    value => value > 5,
    () => "Value too small"
  )
  .getOrElse(0);

// Swap sides for different perspectives
const either = validatePermission(user);
const swapped = either.swap(); // Right becomes Left, Left becomes Right

// Use with scope functions
Right<string, UserData>(userData)
  .let(data => enrichUserData(data))
  .also(enriched => logUserActivity(enriched))
  .apply(data => cacheUserData(data))
  .takeIf(
    data => data.isActive,
    () => "User not active"
  );

// Convert to Option
validateUser(input)
  .toOption() // Some if Right, None if Left
  .map(user => user.id)
  .getOrNull();

Result - Error Handling with Recovery

Result wraps operation outcomes with built-in error handling and recovery strategies.

Basic Usage

import { Success, Failure, tryCatch, tryCatchAsync } from 'kotlinify-ts/monads';

// Creating Results
const success = Success<number, Error>(42);
const failure = Failure<number, Error>(new Error("Operation failed"));

// Safe execution with tryCatch
const result = tryCatch(
  () => JSON.parse(jsonString),
  error => new Error(`Parse error: ${error}`)
);

result
  .map(data => data.users)
  .filter(users => users.length > 0)
  .fold(
    error => console.error("Failed:", error),
    users => console.log("Users:", users)
  );

// Async operations with tryCatchAsync
const apiResult = await tryCatchAsync(
  async () => {
    const response = await fetch("/api/users");
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  },
  error => ({
    message: error instanceof Error ? error.message : "Unknown error",
    timestamp: Date.now()
  })
);

apiResult
  .map(data => data.users)
  .onSuccess(users => updateUI(users))
  .onFailure(error => showError(error))
  .getOrElse([]);

Advanced Recovery Patterns

import { Success, Failure, tryCatch, tryCatchAsync } from 'kotlinify-ts/monads';

// Error recovery strategies
tryCatch(() => loadFromCache())
  .recover(error => {
    console.warn("Cache miss:", error);
    return loadDefault();
  })
  .map(data => processData(data));

// Chain with recoverWith for complex recovery
tryCatchAsync(() => fetchPrimary())
  .recoverWith(async error => {
    console.warn("Primary failed:", error);
    return tryCatchAsync(() => fetchBackup());
  })
  .map(data => normalize(data))
  .getOrElse(cachedData);

// Transform errors
tryCatch(() => riskyOperation())
  .mapError(error => ({
    type: "OPERATION_ERROR",
    message: error.message,
    stack: error.stack,
    retry: true
  }))
  .fold(
    enrichedError => handleError(enrichedError),
    result => handleSuccess(result)
  );

// Combine with scope functions
await tryCatchAsync(() => fetchUserData(userId))
  .let(data => enrichUserData(data))
  .also(enriched => logAnalytics(enriched))
  .apply(data => cacheData(data))
  .onSuccess(data => notifySubscribers(data))
  .onFailure(error => alertOps(error));

Validation - Accumulating Multiple Errors

Stop failing fast. Collect ALL validation errors at once and show them to your users.

The Problem with Early Returns

Traditional validation fails on the first error, forcing users to fix problems one at a time:

// Traditional validation - users see one error at a time
function validateForm(data: FormData) {
  if (!data.email || !data.email.includes('@')) {
    return { error: 'Invalid email' };  // User fixes this...
  }
  if (!data.password || data.password.length < 8) {
    return { error: 'Password too short' };  // ...then sees this...
  }
  if (!data.age || data.age < 18) {
    return { error: 'Must be 18+' };  // ...then sees this. Frustrating!
  }
  return { success: true };
}

This creates a frustrating user experience. Users submit a form, fix one error, submit again, find another error... repeat until insane.

NonEmptyList - A List That's Never Empty

import { NonEmptyList } from 'kotlinify-ts/monads';

// Create a list that's guaranteed to have at least one element
const errors = NonEmptyList.of(
  "Email is required",
  "Password too short",
  "Age must be 18+"
);

// Access head and tail
console.log(errors.head);  // "Email is required"
console.log(errors.tail);  // ["Password too short", "Age must be 18+"]

// Works with all array methods
const formatted = errors.map(err => `• ${err}`);

// Create from array (returns null if empty)
const maybeList = NonEmptyList.fromArray(someArray);
if (maybeList) {
  console.log("At least one item:", maybeList.head);
}

Accumulating Errors with zipOrAccumulate

import { Left, Right, zipOrAccumulate, zipOrAccumulate3, zipOrAccumulate4 } from 'kotlinify-ts/monads';

// Validate multiple independent fields
const emailValidation = validateEmail(formData.email);
const passwordValidation = validatePassword(formData.password);

// Accumulate errors from both validations
const result = zipOrAccumulate(emailValidation, passwordValidation);

result.fold(
  errors => {
    // NonEmptyList<string> with ALL errors
    console.log("Validation failed with errors:", errors);
    errors.forEach(error => displayError(error));
  },
  ([email, password]) => {
    // Both validations passed!
    createAccount(email, password);
  }
);

// Validate 3 fields at once
const validated = zipOrAccumulate3(
  validateUsername(data.username),
  validateEmail(data.email),
  validateAge(data.age)
);

// Validate 4 fields at once
const apiValidation = zipOrAccumulate4(
  validateEndpoint(request.endpoint),
  validateMethod(request.method),
  validateHeaders(request.headers),
  validateBody(request.body)
);

Bulk Validation with mapOrAccumulate

import { Left, Right, mapOrAccumulate } from 'kotlinify-ts/monads';

// Validate an array of items, collecting ALL errors
const userInputs = [
  { name: "Alice", age: 25, email: "alice@example.com" },
  { name: "B", age: 30, email: "bob@example.com" },      // Name too short
  { name: "Charlie", age: 15, email: "charlie@example" }, // Age too young, invalid email
  { name: "David", age: 20, email: "david@example.com" }
];

const validation = mapOrAccumulate(userInputs, user => {
  const errors = [];

  if (user.name.length < 2) {
    return Left(`User ${user.name}: Name too short`);
  }
  if (user.age < 18) {
    return Left(`User ${user.name}: Must be 18+`);
  }
  if (!user.email.includes('@')) {
    return Left(`User ${user.name}: Invalid email`);
  }

  return Right({
    ...user,
    validated: true
  });
});

validation.fold(
  errors => {
    // NonEmptyList with all validation errors
    console.log(`Found ${errors.length} validation errors:`);
    errors.forEach(error => console.error(error));
  },
  validUsers => {
    // All users passed validation!
    console.log(`Successfully validated ${validUsers.length} users`);
    saveUsers(validUsers);
  }
);

Real-World Form Validation

import { Left, Right, zipOrAccumulate3, NonEmptyList, EitherNel } from 'kotlinify-ts/monads';

// Type alias for cleaner signatures
type ValidationError = string;
type Validated<T> = EitherNel<ValidationError, T>;

// Individual field validators
function validateUsername(input?: string): Validated<string> {
  if (!input || input.length < 3) {
    return Left(NonEmptyList.of("Username must be at least 3 characters"));
  }
  if (!/^[a-zA-Z0-9_]+$/.test(input)) {
    return Left(NonEmptyList.of("Username can only contain letters, numbers, and underscores"));
  }
  return Right(input);
}

function validateEmail(input?: string): Validated<string> {
  if (!input) {
    return Left(NonEmptyList.of("Email is required"));
  }
  if (!input.includes('@')) {
    return Left(NonEmptyList.of("Email must contain @"));
  }
  if (!input.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
    return Left(NonEmptyList.of("Invalid email format"));
  }
  return Right(input.toLowerCase());
}

function validatePassword(input?: string): Validated<string> {
  const errors: string[] = [];

  if (!input) {
    errors.push("Password is required");
  } else {
    if (input.length < 8) errors.push("Password must be at least 8 characters");
    if (!/[A-Z]/.test(input)) errors.push("Password must contain uppercase letter");
    if (!/[a-z]/.test(input)) errors.push("Password must contain lowercase letter");
    if (!/[0-9]/.test(input)) errors.push("Password must contain number");
  }

  return errors.length > 0
    ? Left(NonEmptyList.of(errors[0], ...errors.slice(1)))
    : Right(input);
}

// Compose validators to validate entire form
function validateSignupForm(formData: FormData) {
  const username = formData.get('username')?.toString();
  const email = formData.get('email')?.toString();
  const password = formData.get('password')?.toString();

  return zipOrAccumulate3(
    validateUsername(username),
    validateEmail(email),
    validatePassword(password)
  ).map(([username, email, password]) => ({
    username,
    email,
    password,
    createdAt: new Date()
  }));
}

// Usage in a form handler
async function handleSignup(formData: FormData) {
  validateSignupForm(formData).fold(
    errors => {
      // Display ALL validation errors at once
      const errorList = errors.map(e => `<li>${e}</li>`).join('');
      showErrorMessage(`
        <p>Please fix the following errors:</p>
        <ul>${errorList}</ul>
      `);
    },
    async validData => {
      // All validations passed!
      const user = await createUser(validData);
      redirectToDashboard(user);
    }
  );
}

Short-Circuit vs Accumulation

Short-Circuit (Regular Either)
  • • Stops on first error
  • • Good for sequential operations
  • • Use when errors make further validation pointless
  • • Example: Authentication before authorization
validateAuth(token)
  .flatMap(validatePermission)
  .flatMap(validateResource)
// Stops at first failure
Accumulation (zipOrAccumulate)
  • • Collects all errors
  • • Perfect for forms and bulk operations
  • • Shows users everything wrong at once
  • • Example: Form field validation
zipOrAccumulate3(
  validateEmail(email),
  validatePassword(password),
  validateAge(age)
) // Returns all errors

Want more validation patterns? Check out the dedicated Validation Guide for advanced techniques, custom validators, and integration patterns.

Converting Between Monads

Seamlessly convert between different monad types based on your needs.

import { Some, None, Right, Left, Success, Failure } from 'kotlinify-ts/monads';

// Option to Either
const option = fromNullable(getValue());
const either = option.toEither("No value found");
// Some(5) -> Right(5)
// None() -> Left("No value found")

// Option to Result (through fold)
const result = option.fold(
  () => Failure(new Error("Missing value")),
  value => Success(value)
);

// Either to Option
const validated = validateInput(data);
const maybeValid = validated.toOption();
// Right(value) -> Some(value)
// Left(error) -> None()

// Result to Either
const operation = tryCatch(() => performOperation());
const eitherResult = operation.toEither();
// Success(value) -> Right(value)
// Failure(error) -> Left(error)

// Result to Option
const maybeResult = operation.toOption();
// Success(value) -> Some(value)
// Failure(error) -> None()

// Chain across different monads
fromNullable(getUserInput())
  .fold(
    () => Failure(new Error("No input")),
    input => tryCatch(() => processInput(input))
  )
  .toEither()
  .map(processed => formatOutput(processed))
  .fold(
    error => displayError(error),
    output => displaySuccess(output)
  );

Flow Integration

Use monads with Flow for powerful stream processing with error handling.

import { flowOf } from 'kotlinify-ts/flow';
import { Some, None, Success, Failure, Right, Left } from 'kotlinify-ts/monads';

// Filter flow items using Option
flowOf([1, 2, null, 3, undefined, 4])
  .mapNotNull(x => x)  // Removes null/undefined
  .collect(values => console.log(values)); // [1, 2, 3, 4]

// Transform with Option, keeping only Some values
flowOf(["user1", "user2", "user3"])
  .mapToOption(id => fromNullable(findUser(id)))
  .collect(users => console.log("Found users:", users));
  // Only emits users that were found

// Process with Result, keeping only successes
flowOf(["/api/user/1", "/api/user/2", "/api/user/3"])
  .mapToResult(url => tryCatch(() => fetchData(url)))
  .map(data => data.profile)
  .collect(profiles => updateUI(profiles));
  // Only successful fetches are processed

// Validate with Either, keeping only Right values
flowOf(["email1", "email2", "invalid", "email3"])
  .mapToEither(email =>
    email.includes("@")
      ? Right(email)
      : Left("Invalid email")
  )
  .map(email => email.toLowerCase())
  .collect(validEmails => sendNewsletter(validEmails));
  // Only valid emails are collected

// Complex flow pipeline with monads
flowOf(rawDataStream)
  .mapToResult(data => tryCatch(() => parseData(data)))
  .mapToOption(parsed => fromNullable(validate(parsed)))
  .map(valid => transform(valid))
  .filterNotNull()
  .collect(results => saveResults(results));

Real-World Examples

Replace try-catch blocks and complex null checks with elegant monad chains.

import { tryCatchAsync, fromNullable, Right, Left } from 'kotlinify-ts/monads';

// Replace traditional try-catch with Result
async function fetchUserProfile(userId: string) {
  return tryCatchAsync(async () => {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    return response.json();
  })
  .flatMap(data =>
    fromNullable(data.profile)
      .fold(
        () => Failure(new Error("Profile not found")),
        profile => Success(profile)
      )
  )
  .map(profile => ({
    ...profile,
    lastAccessed: Date.now()
  }))
  .also(profile => updateCache(userId, profile))
  .onFailure(error => {
    console.error(`Failed to fetch user ${userId}:`, error);
    trackError(error);
  });
}

// Form validation with Either
function validateForm(formData: FormData) {
  const validateField = (name: string, validator: (value: string) => boolean, error: string) =>
    fromNullable(formData.get(name)?.toString())
      .filter(validator)
      .fold(
        () => Left(error),
        value => Right(value)
      );

  const email = validateField(
    "email",
    email => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email),
    "Invalid email format"
  );

  const password = validateField(
    "password",
    pwd => pwd.length >= 8,
    "Password must be at least 8 characters"
  );

  return email
    .flatMap(() => password)
    .map(() => ({
      email: formData.get("email") as string,
      password: formData.get("password") as string
    }));
}

// Database operation with error handling
async function saveUserPreferences(userId: string, preferences: Preferences) {
  return tryCatchAsync(
    async () => {
      const user = await db.users.findById(userId);
      if (!user) throw new Error("User not found");

      await db.preferences.upsert({
        userId,
        ...preferences,
        updatedAt: new Date()
      });

      return { success: true, userId };
    },
    error => ({
      type: "DB_ERROR",
      message: error instanceof Error ? error.message : "Database operation failed",
      userId,
      timestamp: Date.now()
    })
  )
  .recover(error => {
    // Fallback to cache if DB fails
    localStorage.setItem(`prefs_${userId}`, JSON.stringify(preferences));
    return { success: true, userId, cached: true };
  })
  .also(result => {
    if (result.cached) {
      scheduleRetry(userId, preferences);
    }
  });
}

API Reference

Complete list of available methods for each monad type.

Option Methods

Creation: Some(value), None(), fromNullable(value)
Transform: map(), flatMap(), filter(), filterNot()
Extract: get(), getOrNull(), getOrElse(), getOrThrow()
Compose: orElse(), zip(), fold()
Check: isSome, isNone, contains(), exists()
Scope: let(), also(), apply(), takeIf(), takeUnless()
Convert: toArray(), toEither()

Either Methods

Creation: Left(value), Right(value)
Transform: map(), mapLeft(), flatMap()
Extract: getLeft(), getRight(), getOrNull(), getOrElse()
Compose: fold(), swap(), filterOrElse(), orElse()
Check: isLeft, isRight, contains(), exists()
Scope: let(), also(), apply(), takeIf()
Convert: toOption()

Result Methods

Creation: Success(value), Failure(error), tryCatch(), tryCatchAsync()
Transform: map(), mapError(), flatMap()
Extract: get(), getOrNull(), getOrElse(), getOrThrow(), getError(), getErrorOrNull()
Recover: recover(), recoverWith()
Side Effects: onSuccess(), onFailure()
Check: isSuccess, isFailure
Scope: let(), also(), apply()
Convert: toOption(), toEither()

Validation Methods

NonEmptyList: NonEmptyList.of(head, ...tail), NonEmptyList.fromArray(array)
Properties: head, tail, length, plus all Array methods
Type Alias: EitherNel<E, A> = Either<NonEmptyList<E>, A>
Accumulate 2: zipOrAccumulate(a, b) → Either<NonEmptyList<E>, [A, B]>
Accumulate 3: zipOrAccumulate3(a, b, c) → Either<NonEmptyList<E>, [A, B, C]>
Accumulate 4: zipOrAccumulate4(a, b, c, d) → Either<NonEmptyList<E>, [A, B, C, D]>
Map & Accumulate: mapOrAccumulate(items, fn) → Either<NonEmptyList<E>, B[]>

When to Use Each Monad

Use Option when:

  • • Dealing with nullable values from APIs or databases
  • • Working with optional configuration or settings
  • • Finding elements in collections that may not exist
  • • You don't need error information, just presence/absence

Use Either when:

  • • You need typed error information
  • • Building validation chains with specific error messages
  • • Implementing railway-oriented programming patterns
  • • Working with operations that have two distinct outcomes

Use Result when:

  • • Replacing try-catch blocks with functional error handling
  • • Need error recovery and retry strategies
  • • Working with async operations that may fail
  • • Want to chain operations with side effects for success/failure

Use Validation (zipOrAccumulate) when:

  • • Validating forms where users need to see all errors at once
  • • Processing bulk data where partial success is acceptable
  • • Independent validations that don't depend on each other
  • • Need to accumulate multiple errors instead of failing fast

Next Steps