Project 4: The "Either" Validation Library

Project 4: The “Either” Validation Library

Build an Either monad for error handling without exceptions, with validation that collects all errors at once.

Quick Reference

Attribute Value
Difficulty Intermediate
Time Estimate 1-2 Weeks
Language TypeScript
Prerequisites Project 3 (Maybe Monad), Understanding of generics
Key Topics Bifunctors, Error Handling, Applicative Functors, Validation

1. Learning Objectives

By completing this project, you will:

  1. Understand Either as an extension of Maybe with error information
  2. Learn the difference between short-circuit and accumulating errors
  3. Implement bifunctor operations (bimap, mapLeft)
  4. Build Applicative validation that collects all errors
  5. Master error handling without try/catch
  6. Create a production-ready form validation library
  7. Understand when to use Either vs Maybe vs Exceptions

2. Theoretical Foundation

2.1 Core Concepts

From Maybe to Either: Adding Error Information

Maybe tells you IF something is absent, but not WHY:

type Maybe<T> = Just<T> | Nothing;

const findUser = (id: string): Maybe<User> => { ... };
// Returns Nothing - but why? User not found? Invalid ID? Network error?

Either adds the “why” by carrying error information:

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

const findUser = (id: string): Either<string, User> => { ... };
// Returns Left("User not found") - now we know why!
// Returns Left("Invalid user ID format")
// Returns Left("Database connection failed")

Visual comparison:

Maybe<User>:
┌────────────┐     ┌────────────┐
│  Just(user)│     │  Nothing   │
│     ✓      │     │     ?      │
└────────────┘     └────────────┘
  Success           Failure (why?)

Either<Error, User>:
┌────────────┐     ┌─────────────────────┐
│ Right(user)│     │ Left("User not     │
│     ✓      │     │      found")        │
└────────────┘     └─────────────────────┘
  Success           Failure (with reason!)

Either is Right-Biased

By convention, Right represents success and Left represents failure:

  • Right = “right” as in “correct”
  • Operations like map only affect the Right value
  • Left values pass through unchanged
const result: Either<string, number> = right(5);
result.map(x => x * 2);  // Right(10)

const error: Either<string, number> = left("Oops!");
error.map(x => x * 2);   // Left("Oops!") - unchanged!

Bifunctors: Two Type Parameters, Two Maps

Either has TWO type parameters: Either<E, A>. A bifunctor provides ways to map over both:

// map: Transform the Right (success) value
const map = <E, A, B>(fn: (a: A) => B) =>
  (either: Either<E, A>): Either<E, B> => { ... };

// mapLeft: Transform the Left (error) value
const mapLeft = <E, A, F>(fn: (e: E) => F) =>
  (either: Either<E, A>): Either<F, A> => { ... };

// bimap: Transform both simultaneously
const bimap = <E, A, F, B>(
  leftFn: (e: E) => F,
  rightFn: (a: A) => B
) => (either: Either<E, A>): Either<F, B> => { ... };

Use cases:

// Map the success value
right(5).map(x => x * 2);  // Right(10)

// Map the error (e.g., add context)
left("not found")
  .mapLeft(e => `User lookup failed: ${e}`);
// Left("User lookup failed: not found")

// Transform both
parseResult
  .bimap(
    err => ({ code: 'PARSE_ERROR', message: err }),
    data => data.trim()
  );

Short-Circuit vs Accumulating Errors

FlatMap short-circuits on first error:

const validateName = (n: string): Either<string, string> => ...;
const validateEmail = (e: string): Either<string, string> => ...;
const validateAge = (a: number): Either<string, number> => ...;

// flatMap chains - stops at first error
const validate = (data: Input) =>
  validateName(data.name)
    .flatMap(name => validateEmail(data.email)
      .flatMap(email => validateAge(data.age)
        .map(age => ({ name, email, age }))));

// Input: { name: "", email: "bad", age: -5 }
// Result: Left("Name cannot be empty")  // Only first error!

Applicative validation collects ALL errors:

// Using applicative style
const validate = (data: Input): Either<string[], ValidData> =>
  applicative([
    validateName(data.name),
    validateEmail(data.email),
    validateAge(data.age)
  ]).map(([name, email, age]) => ({ name, email, age }));

// Input: { name: "", email: "bad", age: -5 }
// Result: Left([
//   "Name cannot be empty",
//   "Email format is invalid",
//   "Age must be positive"
// ])  // ALL errors!

Visual comparison:

FlatMap (Short-circuit):
validateName ──Left──▶ STOP (only first error)
      │
      Right
      ▼
validateEmail ──Left──▶ STOP
      │
      Right
      ▼
validateAge ──▶ ...

Applicative (Collect all):
validateName  ──Left──▶ ─────────────────┐
                                         │
validateEmail ──Left──▶ ─────────────────┼──▶ Left([all errors])
                                         │
validateAge   ──Left──▶ ─────────────────┘

Railway-Oriented Programming with Either

           Right Track (Success)
═══════════════════════════════════════════════════▶ Right(result)

           Left Track (Error)
─────────────────────────────────────────────────────▶ Left(error)

┌─────────────┐      ┌─────────────┐      ┌─────────────┐
│   Parse     │──────│  Validate   │──────│    Save     │
│   Input     │      │   Data      │      │   to DB     │
└──────┬──────┘      └──────┬──────┘      └──────┬──────┘
       │                    │                    │
═══════╪════════════════════╪════════════════════╪═════▶
       │                    │                    │
───────╪────────────────────╪────────────────────╪─────▶
       │                    │                    │
Switch to        Switch to          Switch to
Left on          Left on            Left on
parse error      validation error   save error

2.2 Why This Matters

Compared to Exceptions:

// Exceptions: Error handling scattered, invisible in types
function processUser(data: any): User {
  const parsed = JSON.parse(data);      // Throws on invalid JSON
  const validated = validate(parsed);    // Throws on invalid data
  const saved = saveToDb(validated);     // Throws on DB error
  return saved;                          // Looks like it always succeeds!
}

// Either: Errors explicit in types
function processUser(data: string): Either<ProcessError, User> {
  return parseJSON(data)                 // Either<ParseError, object>
    .flatMap(validate)                   // Either<ValidationError, ValidData>
    .flatMap(saveToDb)                   // Either<DbError, User>
    .mapLeft(toProcessError);            // Unified error type
}

In Real Applications:

  • Form Validation: Show all errors at once
  • API Error Handling: Structured error responses
  • Data Pipelines: Track where failures occur
  • Configuration: Validate all settings at startup

2.3 Historical Context

  • Haskell: Either has been standard for decades
  • Scala: Either (originally unbiased, now right-biased)
  • Rust: Result<T, E> (forced to handle errors)
  • Go: Multiple returns (value, error) - similar concept
  • TypeScript: fp-ts, neverthrow libraries

2.4 Common Misconceptions

“Either is just Maybe with an error message”

  • Reality: Either has two independently mappable sides
  • Left can carry complex error types, not just strings

“I should use Either for everything”

  • Reality: Use Maybe when absence has no meaningful “why”
  • Use exceptions for truly exceptional, unrecoverable situations

“Applicative is just flatMap with multiple values”

  • Reality: Applicative is fundamentally different in behavior
  • FlatMap sequences; Applicative runs independently

3. Project Specification

3.1 What You Will Build

A complete Either monad and validation library with:

  • Type-safe Left and Right containers
  • Monadic operations (map, flatMap, bimap, mapLeft)
  • Applicative operations for error accumulation
  • Pattern matching and extraction utilities
  • A form validation DSL built on top
  • Integration utilities (fromTryCatch, toAsync)

3.2 Functional Requirements

  1. Core Types:
    • Either<E, A> union type
    • Left<E> for error values
    • Right<A> for success values
  2. Monadic Operations:
    • Either.left(error): Create Left
    • Either.right(value): Create Right
    • map(fn): Transform Right value
    • mapLeft(fn): Transform Left value
    • bimap(leftFn, rightFn): Transform both
    • flatMap(fn): Chain Either-returning functions
  3. Extraction & Matching:
    • match({ left, right }): Pattern matching
    • getOrElse(default): Extract or use default
    • orElse(alternative): Either-level fallback
    • fold(leftFn, rightFn): Alias for match
    • swap: Exchange Left and Right
  4. Validation (Applicative):
    • validate([Either]): Collect all errors
    • sequence([Either]): All must succeed
    • traverse(array, fn): Map and sequence
  5. Utilities:
    • fromTryCatch(fn, onError): Convert throwing function
    • fromNullable(value, error): Convert nullable
    • toAsync(either): Convert to Promise
  6. Validation DSL:
    • string(): String validators (minLength, maxLength, email, etc.)
    • number(): Number validators (min, max, positive, integer)
    • required(): Non-empty check
    • Compose validators with and(), or()

3.3 Non-Functional Requirements

  • Type Safety: Full generic types, proper type narrowing
  • Error Types: Support complex error types (not just strings)
  • Composability: All operations composable via pipe
  • Zero Dependencies: Pure TypeScript implementation

3.4 Example Usage / Output

// Basic Either usage
const divide = (a: number, b: number): Either<string, number> =>
  b === 0 ? left("Division by zero") : right(a / b);

divide(10, 2).map(x => x * 2);  // Right(10)
divide(10, 0).map(x => x * 2);  // Left("Division by zero")

// Chaining operations
const processData = (input: string): Either<Error, Result> =>
  parseJSON(input)
    .flatMap(validate)
    .flatMap(transform)
    .mapLeft(err => new Error(`Processing failed: ${err}`));

// Validation with error accumulation
interface FormData {
  username: string;
  email: string;
  age: number;
  password: string;
}

const validateForm = (data: FormData): Either<string[], ValidatedForm> =>
  validate([
    validateUsername(data.username),
    validateEmail(data.email),
    validateAge(data.age),
    validatePassword(data.password)
  ]).map(([username, email, age, password]) => ({
    username,
    email,
    age,
    password
  }));

// With invalid data
validateForm({
  username: "ab",           // Too short
  email: "not-an-email",    // Invalid format
  age: -5,                  // Negative
  password: "weak"          // Too simple
});
// Result: Left([
//   "Username must be at least 3 characters",
//   "Email format is invalid",
//   "Age must be positive",
//   "Password must be at least 8 characters",
//   "Password must contain a number"
// ])

// Using validation DSL
const usernameValidator = string()
  .minLength(3, "Username too short")
  .maxLength(20, "Username too long")
  .matches(/^[a-z0-9_]+$/, "Username can only contain lowercase letters, numbers, and underscores");

const emailValidator = string()
  .email("Invalid email format");

usernameValidator.validate("ab");  // Left(["Username too short"])
usernameValidator.validate("valid_user");  // Right("valid_user")

4. Solution Architecture

4.1 High-Level Design

┌───────────────────────────────────────────────────────────┐
│                      Either<E, A>                          │
├───────────────────────────────────────────────────────────┤
│    ┌─────────────────┐         ┌─────────────────┐        │
│    │     Left<E>     │         │     Right<A>    │        │
│    │                 │         │                 │        │
│    │  ┌───────────┐  │         │  ┌───────────┐  │        │
│    │  │  error: E │  │         │  │  value: A │  │        │
│    │  └───────────┘  │         │  └───────────┘  │        │
│    └────────┬────────┘         └────────┬────────┘        │
│             │                           │                 │
│             └───────────┬───────────────┘                 │
│                         │                                 │
│            ┌────────────┴────────────┐                    │
│            │     Monadic Ops         │                    │
│            │  map, flatMap, bimap    │                    │
│            └────────────────────────-┘                    │
│                                                           │
│            ┌────────────────────────┐                     │
│            │   Applicative Ops      │                     │
│            │  validate, sequence    │                     │
│            │  (collect all errors)  │                     │
│            └────────────────────────┘                     │
│                                                           │
│            ┌────────────────────────┐                     │
│            │   Validation DSL       │                     │
│            │  string(), number()    │                     │
│            │  composable validators │                     │
│            └────────────────────────┘                     │
│                                                           │
└───────────────────────────────────────────────────────────┘

4.2 Key Components

Component Responsibility Key Decisions
Either<E, A> Core union type Discriminated with _tag
Left<E> Error container Carries any error type
Right<A> Success container Carries any value type
map/flatMap Right-biased operations Monadic, short-circuits
mapLeft/bimap Bifunctor operations Transform error side
validate Applicative validation Collects ALL errors
Validator<A> Validation DSL Fluent, composable API

4.3 Data Structures

// Core Either types
interface Left<E> {
  readonly _tag: 'Left';
  readonly value: E;
}

interface Right<A> {
  readonly _tag: 'Right';
  readonly value: A;
}

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

// Validation error type
interface ValidationError {
  readonly field: string;
  readonly message: string;
  readonly code?: string;
}

// Validator type
interface Validator<A> {
  validate: (value: unknown) => Either<string[], A>;
  and: (other: Validator<A>) => Validator<A>;
  or: (other: Validator<A>) => Validator<A>;
  map: <B>(fn: (a: A) => B) => Validator<B>;
}

4.4 Algorithm Overview

Applicative Validation:

const validate = <E, A>(
  eithers: Either<E, A>[]
): Either<E[], A[]> => {
  const errors: E[] = [];
  const values: A[] = [];

  for (const either of eithers) {
    switch (either._tag) {
      case 'Left':
        errors.push(either.value);
        break;
      case 'Right':
        values.push(either.value);
        break;
    }
  }

  return errors.length > 0
    ? left(errors)
    : right(values);
};

fromTryCatch:

const fromTryCatch = <E, A>(
  fn: () => A,
  onError: (error: unknown) => E
): Either<E, A> => {
  try {
    return right(fn());
  } catch (error) {
    return left(onError(error));
  }
};

Complexity Analysis:

  • All operations: O(1) except validate/sequence which are O(n)
  • Validation DSL chains: O(m) where m = number of validations

5. Implementation Guide

5.1 Development Environment Setup

mkdir either-validation && cd either-validation
npm init -y
npm install --save-dev typescript ts-node jest @types/jest ts-jest
npx tsc --init
mkdir src tests examples

5.2 Project Structure

either-validation/
├── src/
│   ├── either/
│   │   ├── types.ts        # Either, Left, Right types
│   │   ├── constructors.ts # left, right, fromTryCatch
│   │   ├── operations.ts   # map, flatMap, bimap, etc.
│   │   └── index.ts
│   ├── validation/
│   │   ├── applicative.ts  # validate, sequence, traverse
│   │   ├── validators.ts   # string(), number() DSL
│   │   └── index.ts
│   └── index.ts            # Public API
├── tests/
│   ├── either.test.ts
│   ├── validation.test.ts
│   └── laws.test.ts
├── examples/
│   ├── form-validation.ts
│   └── api-error-handling.ts
└── package.json

5.3 Implementation Phases

Phase 1: Core Either (3-4 hours)

Goals:

  • Define Either type structure
  • Implement constructors
  • Implement map, flatMap, bimap, mapLeft

Tasks:

  1. Create Left<E> and Right<A> interfaces with _tag
  2. Define Either<E, A> union type
  3. Implement left(error) and right(value) constructors
  4. Implement map (only transforms Right)
  5. Implement mapLeft (only transforms Left)
  6. Implement bimap (transforms both)
  7. Implement flatMap for chaining

Checkpoint:

const r = right<string, number>(5);
const l = left<string, number>("error");

expect(r.map(x => x * 2)).toEqual(right(10));
expect(l.map(x => x * 2)).toEqual(left("error"));
expect(l.mapLeft(e => e.toUpperCase())).toEqual(left("ERROR"));

Phase 2: Utilities & Extraction (2-3 hours)

Goals:

  • Pattern matching
  • Extraction utilities
  • Conversion functions

Tasks:

  1. Implement match({ left, right }) for pattern matching
  2. Implement getOrElse(default) for safe extraction
  3. Implement orElse(alternative) for Either-level fallback
  4. Implement swap to exchange Left and Right
  5. Implement fromTryCatch for exception conversion
  6. Implement fromNullable for nullable conversion
  7. Implement toPromise for async interop

Checkpoint:

expect(right(5).getOrElse(0)).toBe(5);
expect(left("err").getOrElse(0)).toBe(0);

const parsed = fromTryCatch(
  () => JSON.parse('{"a": 1}'),
  err => `Parse failed: ${err}`
);
expect(parsed).toEqual(right({ a: 1 }));

Phase 3: Applicative Validation (3-4 hours)

Goals:

  • Error accumulation
  • Array utilities
  • Form validation example

Tasks:

  1. Implement validate([Either]) that collects ALL errors
  2. Implement sequence([Either]) for all-or-nothing
  3. Implement traverse(array, fn) for map + sequence
  4. Create form validation example with multiple fields
  5. Test that errors accumulate correctly

Checkpoint:

const result = validate([
  left("error 1"),
  right("value"),
  left("error 2")
]);
expect(result).toEqual(left(["error 1", "error 2"]));

Phase 4: Validation DSL (3-4 hours)

Goals:

  • Fluent validation API
  • Composable validators
  • Real-world form validation

Tasks:

  1. Create Validator<A> interface
  2. Implement string() with chainable methods
  3. Implement number() with chainable methods
  4. Implement and() and or() combinators
  5. Create comprehensive form validation example

Checkpoint:

const validator = string()
  .minLength(3, "Too short")
  .maxLength(10, "Too long")
  .matches(/^[a-z]+$/, "Only lowercase");

expect(validator.validate("ab")).toEqual(left(["Too short"]));
expect(validator.validate("hello")).toEqual(right("hello"));

5.4 Key Implementation Decisions

Decision Options Recommendation Rationale
Error type Fixed string[] vs Generic E Generic More flexible for custom error types
Validation Class vs Functions Functions + Builder More composable, tree-shakable
Applicative Collect all vs Collect arrays Collect all with flatten Arrays of arrays are confusing
DSL style Method chaining vs Pipe Method chaining More readable for validators

6. Testing Strategy

6.1 Test Categories

Category Purpose Examples
Unit Tests Individual operations map transforms Right only
Law Tests Monad/Functor laws Left identity, right identity
Validation Tests Error accumulation All errors collected
Integration Real-world scenarios Form validation flow

6.2 Critical Test Cases

  1. Right-Biased Map:
    test('map only transforms Right', () => {
      expect(right(5).map(x => x * 2)).toEqual(right(10));
      expect(left("err").map(x => x * 2)).toEqual(left("err"));
    });
    
  2. Error Accumulation: ```typescript test(‘validate collects all Left values’, () => { const result = validate([ left(“a”), left(“b”), right(1), left(“c”) ]); expect(result).toEqual(left([“a”, “b”, “c”])); });

test(‘validate succeeds when all Right’, () => { const result = validate([ right(1), right(2), right(3) ]); expect(result).toEqual(right([1, 2, 3])); });


3. **FlatMap Short-Circuits**:
```typescript
test('flatMap stops at first Left', () => {
  const spy = jest.fn();
  left("first error")
    .flatMap(x => {
      spy();
      return right(x);
    });
  expect(spy).not.toHaveBeenCalled();
});
  1. Monad Laws:
    test('left identity: of(x).flatMap(f) === f(x)', () => {
      const f = (x: number) => right(x * 2);
      expect(right(5).flatMap(f)).toEqual(f(5));
    });
    

6.3 Test Data

// Sample validators
const isPositive = (n: number): Either<string, number> =>
  n > 0 ? right(n) : left("Must be positive");

const isEven = (n: number): Either<string, number> =>
  n % 2 === 0 ? right(n) : left("Must be even");

const isNotEmpty = (s: string): Either<string, string> =>
  s.length > 0 ? right(s) : left("Cannot be empty");

// Form test data
const validForm = {
  username: "johndoe",
  email: "john@example.com",
  age: 25,
  password: "Secure123!"
};

const invalidForm = {
  username: "ab",
  email: "not-email",
  age: -5,
  password: "weak"
};

7. Common Pitfalls & Debugging

7.1 Frequent Mistakes

Pitfall Symptom Solution
Using flatMap when validate needed Only one error returned Use applicative validate for all errors
Mixing Either and Maybe Type errors Convert consistently at boundaries
Forgetting to handle Left Unchecked errors Use match to force handling both
Error type mismatch TypeScript errors Use mapLeft to normalize error types

7.2 Debugging Strategies

// Trace Either operations
const trace = <E, A>(label: string) => (either: Either<E, A>): Either<E, A> => {
  either.match({
    left: (e) => console.log(`${label}: Left(${JSON.stringify(e)})`),
    right: (v) => console.log(`${label}: Right(${JSON.stringify(v)})`)
  });
  return either;
};

// Usage
parseInput(input)
  .pipe(trace('after parse'))
  .flatMap(validate)
  .pipe(trace('after validate'))
  .flatMap(save);

7.3 Performance Traps

  • Creating validators in loops: Pre-create and reuse validators
  • Deep error objects: Keep error data minimal
  • Excessive mapLeft: Batch error transformations

8. Extensions & Challenges

8.1 Beginner Extensions

  • toMaybe: Convert Either to Maybe (discard error)
  • fromMaybe: Convert Maybe to Either with error
  • isLeft/isRight: Type guards

8.2 Intermediate Extensions

  • AsyncEither: Handle async operations returning Either
  • EitherT Transformer: Either monad transformer
  • Nested validation: Validate nested objects

8.3 Advanced Extensions

  • Schema-based validation: Infer types from schema
  • Error recovery: Retry with backoff
  • Accumulating applicative functor: Generalize beyond Either

9. Real-World Connections

9.1 Industry Applications

  • React Hook Form: Error accumulation pattern
  • Zod/Yup: Schema validation libraries
  • NestJS Pipes: Request validation
  • Rust Result: Core error handling pattern
  • fp-ts Either: https://gcanti.github.io/fp-ts/modules/Either.ts.html
  • neverthrow: https://github.com/supermacro/neverthrow
  • io-ts: https://github.com/gcanti/io-ts - Runtime type validation

9.3 Interview Relevance

  • “How do you handle errors without exceptions?”: Show Either
  • “How would you collect all validation errors?”: Show applicative
  • “Design a form validation library”: Direct application

10. Resources

10.1 Essential Reading

  • “Functional Programming in Scala” by Chiusano & Bjarnason - Ch. 4: Handling Errors
  • “Domain Modeling Made Functional” by Scott Wlaschin - Ch. 6, 9: Error handling
  • “Learn You a Haskell” - Ch. 12: Applicative Functors

10.2 Video Resources

  • “Railway Oriented Programming” - Scott Wlaschin (YouTube)
  • “The Error Model” - Joe Duffy (blog post)

10.3 Tools & Documentation

  • fp-ts Either docs: https://gcanti.github.io/fp-ts/modules/Either.ts.html
  • Zod documentation: https://zod.dev - See validation patterns

11. Self-Assessment Checklist

Before considering this project complete, verify:

Understanding

  • I can explain the difference between Maybe and Either
  • I can explain when to use flatMap vs applicative
  • I understand what “right-biased” means
  • I can explain bimap and mapLeft

Implementation

  • Either correctly wraps Left and Right values
  • map only transforms Right, not Left
  • Applicative validation collects ALL errors
  • All monad laws satisfied
  • Validation DSL is composable

Growth

  • I can design error handling strategies using Either
  • I understand when exceptions are still appropriate
  • I can integrate Either with existing Promise-based code

12. Submission / Completion Criteria

Minimum Viable Completion:

  • Either core type implemented
  • map, flatMap, mapLeft, bimap working
  • Pattern matching via match
  • Basic tests passing

Full Completion:

  • Applicative validation collecting all errors
  • Validation DSL with string() and number()
  • All utilities (fromTryCatch, fromNullable, etc.)
  • Comprehensive tests including laws
  • Real-world form validation example

Excellence (Going Above & Beyond):

  • AsyncEither for promise handling
  • Schema inference from validators
  • Published to npm with full documentation
  • Comparison with Zod/Yup approaches

This guide was generated from FUNCTIONAL_PROGRAMMING_TYPESCRIPT_LEARNING_PROJECTS.md. For the complete learning path, see the parent directory.