Project 3: The "Maybe" Null Handler

Project 3: The “Maybe” Null Handler

Build a Maybe monad from scratch to eliminate null/undefined checks forever.

Quick Reference

Attribute Value
Difficulty Intermediate
Time Estimate 1 Week
Language TypeScript
Prerequisites Projects 1-2, Understanding of generics
Key Topics Functors, Monads, Optional Values, Railway-Oriented Programming

1. Learning Objectives

By completing this project, you will:

  1. Understand what a functor is and implement map
  2. Understand what a monad is and implement flatMap
  3. Learn why null/undefined is called the “billion-dollar mistake”
  4. Build a type-safe container for optional values
  5. Eliminate nested null checks with chainable operations
  6. Understand monad laws and why they matter
  7. Apply the Maybe pattern to real-world data access scenarios

2. Theoretical Foundation

2.1 Core Concepts

The Problem: Null and Undefined

Tony Hoare, inventor of null references, called them his “billion-dollar mistake.” Here’s why:

// Typical nullable chain
function getUserCity(userId: string): string | null {
  const user = findUser(userId);        // User | null
  if (!user) return null;
  const address = user.address;          // Address | null
  if (!address) return null;
  const city = address.city;             // string | null
  if (!city) return null;
  return city.toUpperCase();
}

// Problems:
// 1. Repetitive null checks
// 2. Easy to forget a check
// 3. TypeScript narrows types, but it's verbose
// 4. Business logic buried in null-checking code

The issue isn’t that absence exists—it’s that null/undefined are invisible:

function getUser(id: string): User {  // Looks like it always returns User!
  // But it might return null at runtime!
}

Maybe: Making Absence Explicit

Maybe (also called Option) wraps a value that might not exist:

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

class Just<T> {
  constructor(readonly value: T) {}
}

class Nothing {
  // No value inside
}

Now the type tells you: “This might be absent.”

function findUser(id: string): Maybe<User> {
  // Return type FORCES caller to handle absence
}

// Caller MUST deal with Maybe
const user: Maybe<User> = findUser("123");
// user.name  // TypeScript Error! Can't access directly

Functors: Containers You Can Map Over

A functor is any type that implements map correctly. The intuition: “Apply a function to the value inside, keeping it in the container.”

// Arrays are functors
[1, 2, 3].map(x => x * 2);  // [2, 4, 6]

// Maybe is a functor
Just(5).map(x => x * 2);    // Just(10)
Nothing.map(x => x * 2);    // Nothing (no explosion!)

Visual representation:

Array Functor:
┌─────────────────────┐
│  [1, 2, 3]          │
└────────┬────────────┘
         │ .map(x => x * 2)
         ▼
┌─────────────────────┐
│  [2, 4, 6]          │
└─────────────────────┘

Maybe Functor:
┌─────────────────────┐        ┌─────────────────────┐
│  Just(5)            │        │  Nothing            │
└────────┬────────────┘        └────────┬────────────┘
         │ .map(x => x * 2)             │ .map(x => x * 2)
         ▼                              ▼
┌─────────────────────┐        ┌─────────────────────┐
│  Just(10)           │        │  Nothing            │
└─────────────────────┘        └─────────────────────┘

Functor Laws:

  1. Identity: functor.map(x => x) equals functor
  2. Composition: functor.map(f).map(g) equals functor.map(x => g(f(x)))

Monads: Handling Nested Containers

What happens when you map a function that also returns a Maybe?

const getAddress = (user: User): Maybe<Address> => { ... };

const userMaybe: Maybe<User> = findUser("123");
const addressMaybe = userMaybe.map(getAddress);
// Type: Maybe<Maybe<Address>>  ← Nested! Not what we want

This is where flatMap comes in:

const addressMaybe = userMaybe.flatMap(getAddress);
// Type: Maybe<Address>  ← Flattened!

flatMap (also called bind, chain, or >>=) does two things:

  1. Maps the function over the container
  2. Flattens the nested container
map vs flatMap:

map(f):
Just(User) → map(getAddress) → Just(Just(Address))
                                    └── Nested!

flatMap(f):
Just(User) → flatMap(getAddress) → Just(Address)
                                       └── Flattened!

Monad Laws:

  1. Left Identity: Maybe.of(x).flatMap(f) equals f(x)
  2. Right Identity: m.flatMap(Maybe.of) equals m
  3. Associativity: m.flatMap(f).flatMap(g) equals m.flatMap(x => f(x).flatMap(g))

Railway-Oriented Programming

Think of Maybe as a railway with two tracks:

Success Track (Just):
═══════════════════════════════════════════════▶

Failure Track (Nothing):
───────────────────────────────────────────────▶

┌─────────┐      ┌─────────┐      ┌─────────┐
│ findUser│──────│getAddress│─────│ getCity │
└────┬────┘      └────┬────┘      └────┬────┘
     │                │                │
═════╪════════════════╪════════════════╪════════▶ Just(result)
     │                │                │
─────╪────────────────╪────────────────╪────────▶ Nothing
     │                │                │
     ▼                ▼                ▼
  User found?     Has address?     Has city?

Once you’re on the Nothing track, you stay there. No crashes, no errors—just graceful absence.

2.2 Why This Matters

In TypeScript/JavaScript:

  • Optional chaining (?.) is like a built-in Maybe
  • But it doesn’t compose as well as explicit Maybe
// Optional chaining
const city = user?.address?.city?.toUpperCase();

// With Maybe (more composable)
const city = findUser(id)
  .flatMap(u => u.address)
  .flatMap(a => a.city)
  .map(c => c.toUpperCase());

In Modern Libraries:

  • fp-ts: TypeScript library with Option (Maybe) type
  • neverthrow: Result/Option types for TypeScript
  • Effect-TS: Full effect system with Option

In Other Languages:

  • Rust: Option<T> - can’t compile if you don’t handle None
  • Haskell: Maybe a - the original
  • Scala: Option[A]
  • Swift: Optionals (Type?)

2.3 Historical Context

  • 1965: Tony Hoare introduces null reference in ALGOL W
  • 1990: Haskell introduces Maybe monad
  • 2009: Tony Hoare calls null “billion-dollar mistake”
  • 2014: Swift introduces optionals
  • 2015: Rust makes Option mandatory
  • 2020: TypeScript strict mode + nullish coalescing

2.4 Common Misconceptions

“Maybe is just a wrapper, what’s the point?”

  • Reality: The point is TYPE-LEVEL enforcement
  • You CAN’T forget to handle absence when using Maybe

“Optional chaining does the same thing”

  • Reality: Optional chaining returns T | undefined
  • Maybe returns Maybe<T> - you must explicitly extract

“Adding Maybe everywhere is verbose”

  • Reality: You add it at boundaries, not everywhere
  • Internal functions can work with unwrapped values

3. Project Specification

3.1 What You Will Build

A complete Maybe monad implementation with:

  • Type-safe Just and Nothing containers
  • map for transforming values
  • flatMap (chain) for sequencing Maybe-returning operations
  • Pattern matching via match
  • Utilities: getOrElse, orElse, filter, toNullable, fromNullable
  • Array utilities: sequence, traverse

3.2 Functional Requirements

  1. Core Types:
    • Maybe<T> union type
    • Just<T> for present values
    • Nothing for absent values
  2. Core Operations:
    • Maybe.of(value): Create Just
    • Maybe.nothing(): Create Nothing
    • Maybe.fromNullable(value): Convert nullable to Maybe
    • map(fn): Transform inner value
    • flatMap(fn): Chain Maybe-returning functions
    • match({ just, nothing }): Pattern matching
    • getOrElse(default): Extract with fallback
    • orElse(alternative): Maybe-level fallback
    • filter(predicate): Filter based on predicate
  3. Array Utilities:
    • sequence([Maybe]): Convert Maybe[] to Maybe<[]>
    • traverse(array, fn): Map and sequence in one step

3.3 Non-Functional Requirements

  • Type Safety: Full generic types, no any
  • Immutability: Maybe containers never mutate
  • Monad Laws: Implementation must satisfy all three laws
  • Zero Dependencies: Pure TypeScript, no external libraries

3.4 Example Usage / Output

// Basic usage
const x: Maybe<number> = Maybe.of(5);
const y: Maybe<number> = Maybe.nothing();

x.map(n => n * 2);  // Just(10)
y.map(n => n * 2);  // Nothing

// Chaining with flatMap
const getUser = (id: string): Maybe<User> => { ... };
const getAddress = (user: User): Maybe<Address> => { ... };
const getCity = (address: Address): Maybe<string> => { ... };

const city = getUser("123")
  .flatMap(getAddress)
  .flatMap(getCity)
  .map(c => c.toUpperCase())
  .getOrElse("Unknown");

// Pattern matching
const message = city.match({
  just: (c) => `Your city is ${c}`,
  nothing: () => "City not found"
});

// Converting from nullable
const nullableValue: string | null = localStorage.getItem("key");
const maybeValue: Maybe<string> = Maybe.fromNullable(nullableValue);

// Converting to nullable (for interop)
const backToNullable: string | null = maybeValue.toNullable();

// Array utilities
const maybes = [Maybe.of(1), Maybe.of(2), Maybe.of(3)];
const sequenced = Maybe.sequence(maybes);  // Just([1, 2, 3])

const withNothing = [Maybe.of(1), Maybe.nothing(), Maybe.of(3)];
const sequencedWithNothing = Maybe.sequence(withNothing);  // Nothing

4. Solution Architecture

4.1 High-Level Design

┌───────────────────────────────────────────────────────────┐
│                      Maybe<T>                              │
├───────────────────────────────────────────────────────────┤
│                                                           │
│    ┌─────────────────┐         ┌─────────────────┐        │
│    │     Just<T>     │         │     Nothing     │        │
│    │                 │         │                 │        │
│    │  ┌───────────┐  │         │   (no value)    │        │
│    │  │  value: T │  │         │                 │        │
│    │  └───────────┘  │         │                 │        │
│    └────────┬────────┘         └────────┬────────┘        │
│             │                           │                 │
│             └───────────┬───────────────┘                 │
│                         │                                 │
│                         ▼                                 │
│            ┌────────────────────────┐                     │
│            │    Shared Interface    │                     │
│            │  map, flatMap, match   │                     │
│            │  getOrElse, orElse     │                     │
│            └────────────────────────┘                     │
│                                                           │
└───────────────────────────────────────────────────────────┘

4.2 Key Components

Component Responsibility Key Decisions
Maybe<T> type Union of Just and Nothing Discriminated union with _tag
Just<T> Container for present value Readonly value property
Nothing Represents absence Singleton or type with no value
map Transform value if present Returns new Maybe
flatMap Chain Maybe-returning functions Flattens nested Maybe
match Pattern matching Forces handling both cases
Static methods Factory functions of, nothing, fromNullable

4.3 Data Structures

// Discriminated union approach
interface Just<T> {
  readonly _tag: 'Just';
  readonly value: T;
}

interface Nothing {
  readonly _tag: 'Nothing';
}

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

// Factory functions
const just = <T>(value: T): Maybe<T> => ({
  _tag: 'Just',
  value
});

const nothing = <T = never>(): Maybe<T> => ({
  _tag: 'Nothing'
});

// Type guards
const isJust = <T>(m: Maybe<T>): m is Just<T> => m._tag === 'Just';
const isNothing = <T>(m: Maybe<T>): m is Nothing => m._tag === 'Nothing';

4.4 Algorithm Overview

Map Implementation:

const map = <T, U>(fn: (value: T) => U) => (maybe: Maybe<T>): Maybe<U> => {
  switch (maybe._tag) {
    case 'Just':
      return just(fn(maybe.value));
    case 'Nothing':
      return nothing();
  }
};

FlatMap Implementation:

const flatMap = <T, U>(fn: (value: T) => Maybe<U>) => (maybe: Maybe<T>): Maybe<U> => {
  switch (maybe._tag) {
    case 'Just':
      return fn(maybe.value);  // Don't wrap! fn already returns Maybe
    case 'Nothing':
      return nothing();
  }
};

Sequence Implementation:

const sequence = <T>(maybes: Maybe<T>[]): Maybe<T[]> => {
  const result: T[] = [];

  for (const maybe of maybes) {
    if (isNothing(maybe)) {
      return nothing();  // Short-circuit on first Nothing
    }
    result.push(maybe.value);
  }

  return just(result);
};

Complexity:

  • All operations: O(1) except sequence/traverse which are O(n)

5. Implementation Guide

5.1 Development Environment Setup

mkdir maybe-monad && cd maybe-monad
npm init -y
npm install --save-dev typescript ts-node jest @types/jest ts-jest
npx tsc --init
npx ts-jest config:init
mkdir src tests

5.2 Project Structure

maybe-monad/
├── src/
│   ├── maybe.ts          # Core Maybe implementation
│   ├── operators.ts      # map, flatMap, filter, etc.
│   ├── utilities.ts      # sequence, traverse, fromNullable
│   └── index.ts          # Public API
├── tests/
│   ├── maybe.test.ts     # Unit tests
│   ├── laws.test.ts      # Monad law tests
│   └── integration.test.ts  # Real-world scenarios
├── examples/
│   └── api-example.ts    # Demo with fake API
├── package.json
├── tsconfig.json
└── jest.config.js

5.3 Implementation Phases

Phase 1: Core Types (2-3 hours)

Goals:

  • Define Maybe type structure
  • Implement constructors (just, nothing, of, fromNullable)
  • Implement basic type guards

Tasks:

  1. Create discriminated union types
  2. Implement just() and nothing() constructors
  3. Implement Maybe.of() static method
  4. Implement fromNullable() for converting nullable values
  5. Add type guards isJust() and isNothing()

Checkpoint: Can create and identify Maybe values:

const x = just(5);
const y = nothing<number>();
console.log(isJust(x));     // true
console.log(isNothing(y));  // true

Phase 2: Core Operations (3-4 hours)

Goals:

  • Implement map (functor)
  • Implement flatMap (monad)
  • Implement pattern matching
  • Implement extraction utilities

Tasks:

  1. Implement map with correct type inference
  2. Implement flatMap that flattens nested Maybe
  3. Implement match for pattern matching
  4. Implement getOrElse for extracting with default
  5. Implement orElse for Maybe-level fallback
  6. Implement filter for conditional Nothing

Checkpoint: Chaining works correctly:

const result = just(5)
  .map(x => x * 2)
  .flatMap(x => x > 8 ? just(x) : nothing())
  .getOrElse(0);
console.log(result);  // 10

Phase 3: Array Utilities & Polish (2-3 hours)

Goals:

  • Implement sequence and traverse
  • Verify monad laws
  • Create real-world examples

Tasks:

  1. Implement sequence for arrays of Maybes
  2. Implement traverse for mapping and sequencing
  3. Write tests for all three monad laws
  4. Create example demonstrating API data fetching
  5. Add JSDoc comments for all public functions

Checkpoint: All monad laws pass:

// Left identity
expect(Maybe.of(5).flatMap(f)).toEqual(f(5));

// Right identity
expect(m.flatMap(Maybe.of)).toEqual(m);

// Associativity
expect(m.flatMap(f).flatMap(g)).toEqual(m.flatMap(x => f(x).flatMap(g)));

5.4 Key Implementation Decisions

Decision Options Recommendation Rationale
Structure Class vs Functions Functions + Types More FP-idiomatic, tree-shakable
Nothing Singleton vs New Instance New Instance Simpler, type parameter flexibility
API Style Method chaining vs Pipe Both Methods for convenience, functions for composition
Typing Interface vs Type Type + Interface Types for union, interfaces for structure

6. Testing Strategy

6.1 Test Categories

Category Purpose Examples
Unit Tests Test individual functions map transforms Just
Law Tests Verify monad laws Left identity holds
Property Tests Random input testing Composition law for any f, g
Integration Real-world scenarios API response handling

6.2 Critical Test Cases

  1. Functor Identity Law:
    test('map with identity returns same value', () => {
      const identity = <T>(x: T) => x;
      const m = just(5);
      expect(map(identity)(m)).toEqual(m);
    });
    
  2. Monad Left Identity:
    test('flatMap with of equals function application', () => {
      const f = (x: number) => just(x * 2);
      const value = 5;
      expect(Maybe.of(value).flatMap(f)).toEqual(f(value));
    });
    
  3. FlatMap Short-Circuits on Nothing:
    test('flatMap on Nothing does not call function', () => {
      const spy = jest.fn();
      nothing<number>().flatMap(x => {
     spy();
     return just(x * 2);
      });
      expect(spy).not.toHaveBeenCalled();
    });
    
  4. Sequence Fails Fast:
    test('sequence returns Nothing if any element is Nothing', () => {
      const maybes = [just(1), nothing(), just(3)];
      expect(sequence(maybes)).toEqual(nothing());
    });
    

6.3 Test Data

// Sample Maybe values
const justNum = just(42);
const justStr = just("hello");
const justObj = just({ name: "Alice", age: 30 });
const nothingNum = nothing<number>();
const nothingStr = nothing<string>();

// Functions for testing
const double = (x: number) => x * 2;
const safeDiv = (x: number) => (y: number) =>
  y === 0 ? nothing<number>() : just(x / y);
const safeSqrt = (x: number) =>
  x < 0 ? nothing<number>() : just(Math.sqrt(x));

7. Common Pitfalls & Debugging

7.1 Frequent Mistakes

Pitfall Symptom Solution
Using map when flatMap needed Maybe<Maybe<T>> type Use flatMap when function returns Maybe
Forgetting to handle Nothing TypeScript errors Use match or getOrElse
Returning null inside map Nothing doesn’t work Return the value, not null
Mixing Maybe with nullable Confusing types Convert at boundaries with fromNullable

7.2 Debugging Strategies

// Tap function for debugging pipelines
const tap = <T>(label: string) => (m: Maybe<T>): Maybe<T> => {
  m.match({
    just: (v) => console.log(`${label}: Just(${JSON.stringify(v)})`),
    nothing: () => console.log(`${label}: Nothing`)
  });
  return m;
};

// Usage
just(5)
  .map(x => x * 2)
  .pipe(tap('after double'))  // Logs: "after double: Just(10)"
  .flatMap(x => x > 15 ? just(x) : nothing())
  .pipe(tap('after filter'))  // Logs: "after filter: Nothing"

7.3 Performance Traps

  • Creating new functions in map: Pre-define functions
  • Long chains without short-circuit: Nothing already short-circuits
  • Excessive fromNullable/toNullable: Keep values in Maybe within functional code

8. Extensions & Challenges

8.1 Beginner Extensions

  • ap (Apply): Apply Maybe-wrapped function to Maybe-wrapped value
  • zip: Combine two Maybes into Maybe of tuple
  • toArray: Convert Maybe to empty or single-element array

8.2 Intermediate Extensions

  • AsyncMaybe: Handle Promise<Maybe> patterns
  • MaybeT Transformer: Combine Maybe with other monads
  • Do-notation simulation: Cleaner syntax for chaining

8.3 Advanced Extensions

  • Profunctor Maybe: Implement dimap
  • Lazy Maybe: Defer computation until value is needed
  • Validation Mode: Collect all errors instead of short-circuiting

9. Real-World Connections

9.1 Industry Applications

  • TypeScript Optional Chaining: ?. is like built-in Maybe
  • React Query/SWR: data | undefined patterns
  • Form Validation: Handle missing fields gracefully
  • API Responses: Parse partial/missing data safely
  • fp-ts Option: https://gcanti.github.io/fp-ts/modules/Option.ts.html
  • neverthrow: https://github.com/supermacro/neverthrow
  • purify-ts Maybe: https://gigobyte.github.io/purify/adts/Maybe

9.3 Interview Relevance

  • “How would you handle null safely?”: Show Maybe pattern
  • “What is a monad?”: Explain with Maybe example
  • “Implement Optional/Maybe from scratch”: Direct application

10. Resources

10.1 Essential Reading

  • “Learn You a Haskell for Great Good!” by Miran Lipovaca - Ch. 11-12: Functors, Applicatives, Monads
  • “Professor Frisby’s Mostly Adequate Guide to FP” - Ch. 8-9: Functors and Monads
  • “Functional Programming in Scala” by Chiusano & Bjarnason - Ch. 4: Handling Errors

10.2 Video Resources

  • “Monads in JavaScript” - Fun Fun Function (YouTube)
  • “Don’t fear the Monad” - Brian Beckman (Channel 9)

10.3 Tools & Documentation

  • fp-ts Documentation: https://gcanti.github.io/fp-ts/
  • TypeScript Discriminated Unions: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions

11. Self-Assessment Checklist

Before considering this project complete, verify:

Understanding

  • I can explain the difference between map and flatMap
  • I can state all three monad laws from memory
  • I understand why Maybe eliminates null pointer exceptions
  • I can explain railway-oriented programming

Implementation

  • Maybe correctly wraps and unwraps values
  • All monad laws are satisfied (tested)
  • Pattern matching covers both cases
  • fromNullable correctly converts nullable values
  • sequence and traverse work correctly

Growth

  • I can spot null-check patterns that could use Maybe
  • I understand when to use Maybe vs Either
  • I’ve used Maybe in a real project context

12. Submission / Completion Criteria

Minimum Viable Completion:

  • Just and Nothing types implemented
  • map and flatMap working
  • Basic tests passing
  • Pattern matching via match

Full Completion:

  • All utilities implemented (getOrElse, orElse, filter, etc.)
  • sequence and traverse working
  • All monad law tests passing
  • Real-world example demonstrating API usage

Excellence (Going Above & Beyond):

  • Method chaining AND standalone functions
  • AsyncMaybe implementation
  • Published to npm
  • Comprehensive documentation with examples

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