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:
- Understand what a functor is and implement
map - Understand what a monad is and implement
flatMap - Learn why null/undefined is called the “billion-dollar mistake”
- Build a type-safe container for optional values
- Eliminate nested null checks with chainable operations
- Understand monad laws and why they matter
- 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:
- Identity:
functor.map(x => x)equalsfunctor - Composition:
functor.map(f).map(g)equalsfunctor.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:
- Maps the function over the container
- 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:
- Left Identity:
Maybe.of(x).flatMap(f)equalsf(x) - Right Identity:
m.flatMap(Maybe.of)equalsm - Associativity:
m.flatMap(f).flatMap(g)equalsm.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
JustandNothingcontainers mapfor transforming valuesflatMap(chain) for sequencing Maybe-returning operations- Pattern matching via
match - Utilities:
getOrElse,orElse,filter,toNullable,fromNullable - Array utilities:
sequence,traverse
3.2 Functional Requirements
- Core Types:
Maybe<T>union typeJust<T>for present valuesNothingfor absent values
- Core Operations:
Maybe.of(value): Create JustMaybe.nothing(): Create NothingMaybe.fromNullable(value): Convert nullable to Maybemap(fn): Transform inner valueflatMap(fn): Chain Maybe-returning functionsmatch({ just, nothing }): Pattern matchinggetOrElse(default): Extract with fallbackorElse(alternative): Maybe-level fallbackfilter(predicate): Filter based on predicate
- Array Utilities:
sequence([Maybe]): ConvertMaybe[]toMaybe<[]>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:
- Create discriminated union types
- Implement
just()andnothing()constructors - Implement
Maybe.of()static method - Implement
fromNullable()for converting nullable values - Add type guards
isJust()andisNothing()
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:
- Implement
mapwith correct type inference - Implement
flatMapthat flattens nested Maybe - Implement
matchfor pattern matching - Implement
getOrElsefor extracting with default - Implement
orElsefor Maybe-level fallback - Implement
filterfor 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:
- Implement
sequencefor arrays of Maybes - Implement
traversefor mapping and sequencing - Write tests for all three monad laws
- Create example demonstrating API data fetching
- 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
- 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); }); - 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)); }); - 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(); }); - 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 | undefinedpatterns - Form Validation: Handle missing fields gracefully
- API Responses: Parse partial/missing data safely
9.2 Related Open Source Projects
- 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
10.4 Related Projects in This Series
- Previous Project: P02 - Pipe Processor - Composition foundation
- Next Project: P04 - Either Validation - Extends Maybe with error info
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.