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:
- Understand Either as an extension of Maybe with error information
- Learn the difference between short-circuit and accumulating errors
- Implement bifunctor operations (bimap, mapLeft)
- Build Applicative validation that collects all errors
- Master error handling without try/catch
- Create a production-ready form validation library
- 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
maponly 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
LeftandRightcontainers - 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
- Core Types:
Either<E, A>union typeLeft<E>for error valuesRight<A>for success values
- Monadic Operations:
Either.left(error): Create LeftEither.right(value): Create Rightmap(fn): Transform Right valuemapLeft(fn): Transform Left valuebimap(leftFn, rightFn): Transform bothflatMap(fn): Chain Either-returning functions
- Extraction & Matching:
match({ left, right }): Pattern matchinggetOrElse(default): Extract or use defaultorElse(alternative): Either-level fallbackfold(leftFn, rightFn): Alias for matchswap: Exchange Left and Right
- Validation (Applicative):
validate([Either]): Collect all errorssequence([Either]): All must succeedtraverse(array, fn): Map and sequence
- Utilities:
fromTryCatch(fn, onError): Convert throwing functionfromNullable(value, error): Convert nullabletoAsync(either): Convert to Promise
- 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:
- Create
Left<E>andRight<A>interfaces with_tag - Define
Either<E, A>union type - Implement
left(error)andright(value)constructors - Implement
map(only transforms Right) - Implement
mapLeft(only transforms Left) - Implement
bimap(transforms both) - Implement
flatMapfor 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:
- Implement
match({ left, right })for pattern matching - Implement
getOrElse(default)for safe extraction - Implement
orElse(alternative)for Either-level fallback - Implement
swapto exchange Left and Right - Implement
fromTryCatchfor exception conversion - Implement
fromNullablefor nullable conversion - Implement
toPromisefor 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:
- Implement
validate([Either])that collects ALL errors - Implement
sequence([Either])for all-or-nothing - Implement
traverse(array, fn)for map + sequence - Create form validation example with multiple fields
- 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:
- Create
Validator<A>interface - Implement
string()with chainable methods - Implement
number()with chainable methods - Implement
and()andor()combinators - 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
- 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")); }); - 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();
});
- 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
9.2 Related Open Source Projects
- 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
10.4 Related Projects in This Series
- Previous Project: P03 - Maybe Monad - Foundation
- Next Project: P05 - JSON Parser - Uses Either for parse errors
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.