โ† Back to all posts

Mastering fp-ts: Build a Recipe Manager While Learning Functional Programming

๐Ÿ“– About This Tutorial

This is a complete, practical tutorial that teaches fp-ts by building a real Recipe Manager application with Next.js. Every concept is learned by implementing actual features.

What we're building: - Create, read, update, delete recipes - Search and filter recipes - Handle errors gracefully - Manage cooking sessions - Calculate statistics and aggregations - Scale recipes for different servings - Import/export recipes

What you'll master: - Option & Either - Safe null handling and error management - Task & TaskEither - Async operations - Reader & ReaderTaskEither - Dependency injection - State Monad - Stateful computations - Semigroup & Monoid - Data combination - Functor, Applicative, Monad - Core abstractions - Array operations - Functional data transformations - Eq & Ord - Equality and ordering - pipe & flow - Function composition


๐Ÿš€ Project Setup

# Create Next.js project
npx create-next-app@latest recipe-manager --typescript --tailwind --app
cd recipe-manager

# Install fp-ts and utilities (NO io-ts - pure fp-ts only!)
npm install fp-ts date-fns uuid
npm install -D @types/uuid

# Create directory structure
mkdir -p lib/domain lib/recipes lib/core components
mkdir -p app/recipes/create app/recipes/[id]
mkdir -p data

Chapter 1: Your First Feature - Creating Recipes with Option

What we're building: Recipe creation form with optional fields

fp-ts concepts: Option, pipe, map, fold, Do notation

Why Option?

In traditional JavaScript/TypeScript, we use null or undefined for missing values. This leads to: - Runtime errors (Cannot read property of undefined) - Defensive null checks everywhere - Unclear intent (is null valid or an error?)

Option solves this:

// โŒ Traditional approach
interface User {
  name: string;
  email: string | null;  // Is null allowed?
}

function greet(user: User): string {
  if (user.email === null) {  // Must remember to check!
    return `Hello ${user.name}!`;
  }
  return `Hello ${user.name} (${user.email})!`;
}

// โœ… fp-ts Option approach
import * as O from 'fp-ts/Option';

interface User {
  name: string;
  email: O.Option<string>;  // Explicitly optional!
}

function greet(user: User): string {
  return pipe(
    user.email,
    O.fold(
      () => `Hello ${user.name}!`,
      (email) => `Hello ${user.name} (${email})!`
    )
  );
}
// Compiler forces us to handle both cases!

Step 1: Define Domain Models

// lib/domain/recipe.ts
import * as O from 'fp-ts/Option';

export interface Recipe {
  readonly id: string;
  readonly title: string;
  readonly description: O.Option<string>;  // Optional - might not exist
  readonly servings: number;
  readonly prepTime: number; // in minutes
  readonly cookTime: number;
  readonly ingredients: readonly Ingredient[];
  readonly steps: readonly string[];
  readonly tags: readonly string[];
  readonly createdAt: Date;
}

export interface Ingredient {
  readonly name: string;
  readonly amount: O.Option<number>;  // "salt to taste" has no amount
  readonly unit: O.Option<string>;    // Some amounts have no unit
}

Understanding Option:

// Option is a discriminated union:
type Option<A> = Some<A> | None;

// Creating Options
const some = O.some(42);        // Some(42) - has a value
const none = O.none;             // None - no value

// Checking what we have
O.isSome(some);  // true
O.isNone(none);  // true

// Extracting values (unsafe - only use after checking!)
if (O.isSome(some)) {
  const value = some.value;  // 42
}

// Safe extraction with default
O.getOrElse(() => 0)(some);  // 42
O.getOrElse(() => 0)(none);  // 0

Step 2: Understanding pipe

Before we continue, let's master pipe - it's fundamental to fp-ts:

// lib/core/pipe-tutorial.ts
import { pipe } from 'fp-ts/function';

// Without pipe - nested, hard to read (right to left)
const result1 = Math.sqrt(Math.abs(-16));

// With pipe - clear, left to right
const result2 = pipe(
  -16,
  Math.abs,   // 16
  Math.sqrt   // 4
);

// More complex example
const add = (x: number) => (y: number) => x + y;
const multiply = (x: number) => (y: number) => x * y;
const square = (x: number) => x * x;

// Without pipe
const ugly = square(multiply(2)(add(3)(5)));  // ((5 + 3) * 2) ^ 2 = 256

// With pipe - reads like a recipe!
const clean = pipe(
  5,
  add(3),      // 8
  multiply(2), // 16
  square       // 256
);

console.log(ugly === clean);  // true

Why pipe? - Readability: Operations flow left-to-right like English - Debugging: Easy to add console.log between steps - Composition: Build complex operations from simple ones

Step 3: Working with Option - Parsing User Input

// lib/recipes/create.ts
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';

/**
 * Convert empty string to None, non-empty to Some
 */
export const fromEmptyString = (s: string): O.Option<string> =>
  pipe(
    s.trim(),
    trimmed => trimmed.length === 0 ? O.none : O.some(trimmed)
  );

// Examples
console.log(fromEmptyString(""));         // None
console.log(fromEmptyString("  "));       // None
console.log(fromEmptyString("hello"));    // Some("hello")

/**
 * Parse string to number safely
 */
export const parseNumber = (s: string): O.Option<number> =>
  pipe(
    parseFloat(s),
    n => isNaN(n) ? O.none : O.some(n)
  );

console.log(parseNumber("42"));    // Some(42)
console.log(parseNumber("3.14"));  // Some(3.14)
console.log(parseNumber("abc"));   // None

/**
 * Parse positive number
 */
export const parsePositiveNumber = (s: string): O.Option<number> =>
  pipe(
    parseNumber(s),
    O.filter(n => n > 0)  // Keep only if > 0
  );

console.log(parsePositiveNumber("5"));    // Some(5)
console.log(parsePositiveNumber("-5"));   // None
console.log(parsePositiveNumber("0"));    // None
console.log(parsePositiveNumber("abc"));  // None

Understanding O.filter:

// filter keeps the value only if predicate is true
const isEven = (n: number) => n % 2 === 0;

pipe(O.some(4), O.filter(isEven));  // Some(4) - kept
pipe(O.some(5), O.filter(isEven));  // None - filtered out
pipe(O.none, O.filter(isEven));     // None - stays None

// Real-world example
const validateAge = (age: number): O.Option<number> =>
  pipe(
    O.some(age),
    O.filter(a => a >= 18),
    O.filter(a => a <= 120)
  );

validateAge(25);   // Some(25)
validateAge(15);   // None - too young
validateAge(150);  // None - unrealistic

Step 4: Create Recipe Component

// app/recipes/create/page.tsx
'use client';

import { useState } from 'react';
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';

export default function CreateRecipePage() {
  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');
  const [servings, setServings] = useState('4');
  const [prepTime, setPrepTime] = useState('15');
  const [cookTime, setCookTime] = useState('30');

  return (
    <div className="max-w-2xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">Create New Recipe</h1>

      <form className="space-y-4">
        <div>
          <label className="block font-medium mb-2">Title *</label>
          <input
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            className="w-full border rounded px-3 py-2"
            placeholder="Chocolate Chip Cookies"
          />
        </div>

        <div>
          <label className="block font-medium mb-2">
            Description (optional)
          </label>
          <textarea
            value={description}
            onChange={(e) => setDescription(e.target.value)}
            className="w-full border rounded px-3 py-2"
            placeholder="Delicious homemade cookies..."
            rows={3}
          />
        </div>

        <div className="grid grid-cols-3 gap-4">
          <div>
            <label className="block font-medium mb-2">Servings *</label>
            <input
              type="number"
              value={servings}
              onChange={(e) => setServings(e.target.value)}
              className="w-full border rounded px-3 py-2"
            />
          </div>
          <div>
            <label className="block font-medium mb-2">Prep Time (min) *</label>
            <input
              type="number"
              value={prepTime}
              onChange={(e) => setPrepTime(e.target.value)}
              className="w-full border rounded px-3 py-2"
            />
          </div>
          <div>
            <label className="block font-medium mb-2">Cook Time (min) *</label>
            <input
              type="number"
              value={cookTime}
              onChange={(e) => setCookTime(e.target.value)}
              className="w-full border rounded px-3 py-2"
            />
          </div>
        </div>
      </form>
    </div>
  );
}

Step 5: Combining Multiple Options - Do Notation

Now the interesting part: combining multiple optional values!

// lib/recipes/create.ts (continued)
import { v4 as uuidv4 } from 'uuid';
import { Recipe } from '../domain/recipe';

interface RecipeFormData {
  readonly title: string;
  readonly description: string;
  readonly servings: string;
  readonly prepTime: string;
  readonly cookTime: string;
}

/**
 * Create Recipe from form data
 * Returns None if any required field is invalid
 */
export const createRecipe = (formData: RecipeFormData): O.Option<Recipe> => {
  // Parse all fields
  const title = fromEmptyString(formData.title);
  const servings = parsePositiveNumber(formData.servings);
  const prepTime = parsePositiveNumber(formData.prepTime);
  const cookTime = parsePositiveNumber(formData.cookTime);

  // โŒ BAD - Manual checking is ugly!
  if (O.isNone(title) || O.isNone(servings) || 
      O.isNone(prepTime) || O.isNone(cookTime)) {
    return O.none;
  }

  return O.some({
    id: uuidv4(),
    title: title.value,
    description: fromEmptyString(formData.description),
    servings: servings.value,
    prepTime: prepTime.value,
    cookTime: cookTime.value,
    ingredients: [],
    steps: [],
    tags: [],
    createdAt: new Date()
  });
};

// โœ… BETTER - Using Do notation!
export const createRecipeFP = (formData: RecipeFormData): O.Option<Recipe> =>
  pipe(
    O.Do,  // Start with Some({})
    O.apS('title', fromEmptyString(formData.title)),
    O.apS('servings', parsePositiveNumber(formData.servings)),
    O.apS('prepTime', parsePositiveNumber(formData.prepTime)),
    O.apS('cookTime', parsePositiveNumber(formData.cookTime)),
    O.map(({ title, servings, prepTime, cookTime }) => ({
      id: uuidv4(),
      title,
      description: fromEmptyString(formData.description),
      servings,
      prepTime,
      cookTime,
      ingredients: [],
      steps: [],
      tags: [],
      createdAt: new Date()
    }))
  );

Understanding Do Notation:

// Do notation combines multiple Options into one
// If ALL are Some, you get Some(combined object)
// If ANY is None, you get None

// Example
const result = pipe(
  O.Do,                          // Some({})
  O.apS('a', O.some(1)),         // Some({ a: 1 })
  O.apS('b', O.some(2)),         // Some({ a: 1, b: 2 })
  O.apS('c', O.some(3)),         // Some({ a: 1, b: 2, c: 3 })
  O.map(({ a, b, c }) => a + b + c)  // Some(6)
);

// If any is None, everything becomes None
const failed = pipe(
  O.Do,
  O.apS('a', O.some(1)),
  O.apS('b', O.none),  // None here!
  O.apS('c', O.some(3)),
  O.map(({ a, b, c }) => a + b + c)
);
// failed = None (never reaches map)

Understanding O.map:

// map transforms the value inside Option
pipe(
  O.some(5),
  O.map(x => x * 2)  // Some(10)
);

pipe(
  O.none,
  O.map(x => x * 2)  // None - map doesn't run
);

// Real example
const greetUser = (name: O.Option<string>): O.Option<string> =>
  pipe(
    name,
    O.map(n => `Hello, ${n}!`)
  );

greetUser(O.some("Alice"));  // Some("Hello, Alice!")
greetUser(O.none);            // None

Step 6: Handle Form Submission with fold

// app/recipes/create/page.tsx (updated)
'use client';

import { useState } from 'react';
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
import { createRecipeFP } from '@/lib/recipes/create';

export default function CreateRecipePage() {
  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');
  const [servings, setServings] = useState('4');
  const [prepTime, setPrepTime] = useState('15');
  const [cookTime, setCookTime] = useState('30');
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    const recipeOption = createRecipeFP({
      title,
      description,
      servings,
      prepTime,
      cookTime
    });

    pipe(
      recipeOption,
      O.fold(
        // None case - validation failed
        () => {
          setError('Please fill in all required fields with valid values');
        },
        // Some case - recipe created successfully
        (recipe) => {
          console.log('Recipe created:', recipe);
          setError(null);
          alert(`Recipe "${recipe.title}" created!`);
          // Reset form
          setTitle('');
          setDescription('');
          setServings('4');
          setPrepTime('15');
          setCookTime('30');
        }
      )
    );
  };

  return (
    <div className="max-w-2xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">Create New Recipe</h1>

      {error && (
        <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
          {error}
        </div>
      )}

      <form onSubmit={handleSubmit} className="space-y-4">
        {/* ... same form fields as before ... */}

        <button
          type="submit"
          className="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700"
        >
          Create Recipe
        </button>
      </form>
    </div>
  );
}

Understanding O.fold:

// fold (also called "match") handles both cases
pipe(
  optionValue,
  O.fold(
    () => handleNone(),      // What to do if None
    (value) => handleSome(value)  // What to do if Some
  )
);

// Both branches must return the same type
const getDisplayName = (name: O.Option<string>): string =>
  pipe(
    name,
    O.fold(
      () => "Anonymous",           // string
      (n) => n.toUpperCase()       // string
    )
  );

// fold is like a safe if/else for Options
getDisplayName(O.some("alice"));  // "ALICE"
getDisplayName(O.none);            // "Anonymous"

โœ… Chapter 1 Summary

What we built: Recipe creation form with validation

What we learned: - Option type - Safe null handling - pipe - Left-to-right function composition - O.map - Transform values inside Option - O.filter - Keep values that match predicate - O.fold - Pattern match on Option (handle both cases) - Do notation - Combine multiple Options - Pure functions - All functions are deterministic, no side effects

Key insight: Option forces us to handle missing values explicitly, making bugs impossible!


Chapter 2: Adding Ingredients - Array Operations and chain

What we're building: Dynamic ingredient list with add/remove functionality

fp-ts concepts: Array operations, chain (flatMap), Functor

Step 1: Ingredient Form Component

// app/recipes/create/page.tsx (add to existing component)

import * as A from 'fp-ts/Array';
import { Ingredient } from '@/lib/domain/recipe';

export default function CreateRecipePage() {
  // ... existing state ...
  const [ingredients, setIngredients] = useState<Ingredient[]>([]);
  const [ingredientName, setIngredientName] = useState('');
  const [ingredientAmount, setIngredientAmount] = useState('');
  const [ingredientUnit, setIngredientUnit] = useState('');

  const addIngredient = () => {
    const newIngredient: Ingredient = {
      name: ingredientName,
      amount: pipe(
        fromEmptyString(ingredientAmount),
        O.chain(parseNumber)  // Parse string to number if not empty
      ),
      unit: fromEmptyString(ingredientUnit)
    };

    setIngredients(prev => pipe(
      prev,
      A.append(newIngredient)  // Immutably add to end
    ));

    // Clear inputs
    setIngredientName('');
    setIngredientAmount('');
    setIngredientUnit('');
  };

  return (
    <div className="max-w-2xl mx-auto p-6">
      {/* ... existing recipe form ... */}

      <div className="border-t pt-4 mt-4">
        <h2 className="text-xl font-bold mb-4">Ingredients</h2>

        <div className="grid grid-cols-12 gap-2 mb-4">
          <input
            type="text"
            value={ingredientName}
            onChange={(e) => setIngredientName(e.target.value)}
            placeholder="Ingredient name"
            className="col-span-5 border rounded px-3 py-2"
          />
          <input
            type="text"
            value={ingredientAmount}
            onChange={(e) => setIngredientAmount(e.target.value)}
            placeholder="Amount (optional)"
            className="col-span-3 border rounded px-3 py-2"
          />
          <input
            type="text"
            value={ingredientUnit}
            onChange={(e) => setIngredientUnit(e.target.value)}
            placeholder="Unit (optional)"
            className="col-span-3 border rounded px-3 py-2"
          />
          <button
            type="button"
            onClick={addIngredient}
            disabled={!ingredientName.trim()}
            className="col-span-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-400"
          >
            +
          </button>
        </div>

        <IngredientList 
          ingredients={ingredients} 
          setIngredients={setIngredients} 
        />
      </div>
    </div>
  );
}

Understanding O.chain:

// chain (also called flatMap or bind) sequences operations that return Options

// Problem: We have Option<string>, but parseNumber returns Option<number>
// If we use map, we get Option<Option<number>> - nested!

const stringOpt: O.Option<string> = O.some("42");

// โŒ Using map - creates nested Option
const nested: O.Option<O.Option<number>> = pipe(
  stringOpt,
  O.map(parseNumber)  // Option<Option<number>> ๐Ÿ˜ข
);

// โœ… Using chain - automatically flattens
const flat: O.Option<number> = pipe(
  stringOpt,
  O.chain(parseNumber)  // Option<number> ๐Ÿ˜Š
);

// Real example
const getUserAge = (userId: string): O.Option<number> => {
  // Get user (might not exist)
  const user = findUser(userId);  // Option<User>

  // Get age from user
  return pipe(
    user,
    O.chain(u => u.age)  // If no user, chain doesn't run
  );
};

// Another example - chaining multiple operations
const processInput = (input: string): O.Option<number> =>
  pipe(
    fromEmptyString(input),     // Option<string>
    O.chain(parseNumber),        // Option<number>
    O.chain(ensurePositive),     // Option<number>
    O.chain(ensureLessThan100)   // Option<number>
  );
// If ANY step returns None, the whole thing is None

Step 2: Array Operations Deep Dive

// lib/core/array-tutorial.ts
import * as A from 'fp-ts/Array';
import { pipe } from 'fp-ts/function';

const numbers = [1, 2, 3, 4, 5];

// ===== TRANSFORMING =====

// map - transform each element
const doubled = pipe(
  numbers,
  A.map(n => n * 2)
);
// [2, 4, 6, 8, 10]

// mapWithIndex - transform with access to index
const indexed = pipe(
  numbers,
  A.mapWithIndex((i, n) => `${i}: ${n}`)
);
// ["0: 1", "1: 2", "2: 3", "3: 4", "4: 5"]

// ===== FILTERING =====

// filter - keep elements matching predicate
const evens = pipe(
  numbers,
  A.filter(n => n % 2 === 0)
);
// [2, 4]

// filterWithIndex - filter with access to index
const oddIndices = pipe(
  numbers,
  A.filterWithIndex((i, n) => i % 2 === 1)
);
// [2, 4] - elements at indices 1, 3

// filterMap - combine filter and map
const parsePositive = (s: string): O.Option<number> =>
  pipe(
    parseNumber(s),
    O.filter(n => n > 0)
  );

const strings = ["1", "-2", "abc", "3"];
const validNumbers = pipe(
  strings,
  A.filterMap(parsePositive)
);
// [1, 3] - only valid positive numbers

// ===== ADDING/REMOVING =====

// append - add to end (immutable!)
const appended = pipe(numbers, A.append(6));
// [1, 2, 3, 4, 5, 6]
console.log(numbers);  // [1, 2, 3, 4, 5] - original unchanged!

// prepend - add to beginning
const prepended = pipe(numbers, A.prepend(0));
// [0, 1, 2, 3, 4, 5]

// deleteAt - remove at index
const deleted = pipe(
  numbers,
  A.deleteAt(2)  // Returns Option<Array>!
);
// Some([1, 2, 4, 5]) - removed index 2

// updateAt - change value at index
const updated = pipe(
  numbers,
  A.updateAt(2, 99)  // Returns Option<Array>!
);
// Some([1, 2, 99, 4, 5])

// ===== ACCESSING =====

// head - get first element
const first = A.head(numbers);  // Some(1)
const emptyFirst = A.head([]);  // None

// tail - get all but first
const rest = A.tail(numbers);  // Some([2, 3, 4, 5])
const emptyRest = A.tail([]);  // None

// last - get last element
const lastEl = A.last(numbers);  // Some(5)

// lookup - get element at index
const atIndex2 = pipe(numbers, A.lookup(2));  // Some(3)
const atIndex10 = pipe(numbers, A.lookup(10)); // None

// ===== CHECKING =====

// isEmpty - check if empty
A.isEmpty([]);      // true
A.isEmpty(numbers); // false

// isNonEmpty - check if has elements
A.isNonEmpty(numbers);  // true
A.isNonEmpty([]);       // false

// elem - check if element exists (needs Eq)
import * as N from 'fp-ts/number';
pipe(numbers, A.elem(N.Eq)(3));  // true
pipe(numbers, A.elem(N.Eq)(10)); // false

// ===== COMBINING =====

// concat - combine two arrays
A.concat([1, 2], [3, 4]);  // [1, 2, 3, 4]

// flatten - remove one level of nesting
const nested = [[1, 2], [3, 4], [5]];
A.flatten(nested);  // [1, 2, 3, 4, 5]

// ===== IMPORTANT: All operations return NEW arrays =====
const original = [1, 2, 3];
const modified = pipe(original, A.append(4));

console.log(original);  // [1, 2, 3] - unchanged!
console.log(modified);  // [1, 2, 3, 4] - new array

Understanding Functor (Array as Functor):

// A Functor is anything with a map function
// It lets you transform values inside a "container"

// Array is a Functor
A.map((x: number) => x * 2)([1, 2, 3]);  // [2, 4, 6]

// Option is a Functor
O.map((x: number) => x * 2)(O.some(5));  // Some(10)

// Either is a Functor
E.map((x: number) => x * 2)(E.right(5)); // Right(10)

// Functor Laws:
// 1. Identity: map(x => x) doesn't change anything
pipe([1, 2, 3], A.map(x => x));  // [1, 2, 3]

// 2. Composition: map(f).map(g) === map(x => g(f(x)))
const f = (x: number) => x + 1;
const g = (x: number) => x * 2;

const way1 = pipe([1, 2, 3], A.map(f), A.map(g));
const way2 = pipe([1, 2, 3], A.map(x => g(f(x))));
// Both give [4, 6, 8]

// Why Functors matter: You can work with values in context
// without worrying about the context!

Step 3: Display and Manage Ingredients

// components/IngredientList.tsx
import * as A from 'fp-ts/Array';
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
import { Ingredient } from '@/lib/domain/recipe';

interface Props {
  ingredients: readonly Ingredient[];
  setIngredients: (ingredients: Ingredient[]) => void;
}

export function IngredientList({ ingredients, setIngredients }: Props) {
  const removeIngredient = (index: number) => {
    setIngredients(pipe(
      ingredients,
      A.filterWithIndex((i) => i !== index)
    ));
  };

  const formatIngredient = (ing: Ingredient): string =>
    pipe(
      ing.amount,
      O.fold(
        // No amount - just name
        () => ing.name,
        (amount) => pipe(
          ing.unit,
          O.fold(
            // Amount but no unit
            () => `${amount} ${ing.name}`,
            // Amount and unit
            (unit) => `${amount} ${unit} ${ing.name}`
          )
        )
      )
    );

  if (A.isEmpty(ingredients)) {
    return (
      <div className="text-gray-500 text-sm italic">
        No ingredients added yet
      </div>
    );
  }

  return (
    <ul className="space-y-2">
      {pipe(
        ingredients,
        A.mapWithIndex((index, ingredient) => (
          <li 
            key={index} 
            className="flex justify-between items-center bg-gray-50 p-3 rounded"
          >
            <span>{formatIngredient(ingredient)}</span>
            <button
              type="button"
              onClick={() => removeIngredient(index)}
              className="text-red-600 hover:text-red-800 font-bold"
            >
              ร—
            </button>
          </li>
        ))
      )}
    </ul>
  );
}

Step 4: Understanding Eq - Equality for Custom Types

// lib/core/eq-tutorial.ts
import * as Eq from 'fp-ts/Eq';
import * as S from 'fp-ts/string';
import * as N from 'fp-ts/number';
import * as A from 'fp-ts/Array';
import { pipe } from 'fp-ts/function';

// Eq defines equality for a type
// interface Eq<A> {
//   equals: (x: A, y: A) => boolean
// }

// Built-in Eqs
S.Eq.equals("hello", "hello");  // true
S.Eq.equals("hello", "world");  // false

N.Eq.equals(5, 5);  // true
N.Eq.equals(5, 3);  // false

// Using Eq with arrays
const numbers = [1, 2, 3, 4, 5];

// Check if element exists
pipe(numbers, A.elem(N.Eq)(3));  // true
pipe(numbers, A.elem(N.Eq)(10)); // false

// Remove duplicates
const withDups = [1, 2, 2, 3, 3, 3];
pipe(withDups, A.uniq(N.Eq));  // [1, 2, 3]

// Custom Eq for our types
interface Ingredient {
  readonly name: string;
  readonly amount: O.Option<number>;
  readonly unit: O.Option<string>;
}

// Compare ingredients by name only
const IngredientByNameEq: Eq.Eq<Ingredient> = {
  equals: (a, b) => a.name === b.name
};

const flour1: Ingredient = { 
  name: "flour", 
  amount: O.some(200), 
  unit: O.some("g") 
};

const flour2: Ingredient = { 
  name: "flour", 
  amount: O.some(300),  // Different amount
  unit: O.some("g") 
};

const sugar: Ingredient = { 
  name: "sugar", 
  amount: O.some(100), 
  unit: O.some("g") 
};

IngredientByNameEq.equals(flour1, flour2);  // true - same name!
IngredientByNameEq.equals(flour1, sugar);   // false

// Use with arrays
const ingredients = [flour1, sugar];
pipe(
  ingredients,
  A.elem(IngredientByNameEq)(flour2)  // true - flour exists
);

// Combining Eqs
import { contramap } from 'fp-ts/Eq';

// Create Eq<Ingredient> from Eq<string> by extracting name
const IngredientEq: Eq.Eq<Ingredient> = pipe(
  S.Eq,
  contramap((ing: Ingredient) => ing.name)
);

IngredientEq.equals(flour1, flour2);  // true

โœ… Chapter 2 Summary

What we built: Dynamic ingredient list with add/remove

What we learned: - Array operations - map, filter, append, etc. - O.chain - Sequence operations that return Options - Functor - Transform values in containers - Eq type class - Define equality for custom types - Immutability - All operations create new arrays

Key insight: fp-ts Array operations are immutable and type-safe, preventing bugs!


Chapter 3: Error Handling with Either

What we're building: Recipe validation and saving with proper error handling

fp-ts concepts: Either, error types, TaskEither, async operations

Why Either?

Option tells us if something is missing, but not WHY. Either gives us detailed error information:

// โŒ Option - no error info
O.none  // Something failed, but we don't know what

// โœ… Either - includes error details
E.left("Email is invalid")
E.left("Age must be positive")
E.left("Network timeout")

// Either<E, A> is:
// - Left(error) for failures
// - Right(value) for success

Step 1: Understanding Either

// lib/core/either-tutorial.ts
import * as E from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';

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

// Creating Eithers
const success = E.right(42);              // Right(42)
const failure = E.left("Something broke"); // Left("Something broke")

// Checking which variant
E.isRight(success);  // true
E.isLeft(failure);   // true

// Division that can fail
const safeDivide = (a: number, b: number): E.Either<string, number> =>
  b === 0
    ? E.left("Cannot divide by zero")
    : E.right(a / b);

safeDivide(10, 2);  // Right(5)
safeDivide(10, 0);  // Left("Cannot divide by zero")

// Pattern matching with fold
pipe(
  safeDivide(10, 2),
  E.fold(
    (error) => `Error: ${error}`,
    (value) => `Result: ${value}`
  )
);
// "Result: 5"

// Chaining operations that can fail
const calculate = (x: number, y: number, z: number): E.Either<string, number> =>
  pipe(
    safeDivide(x, y),
    E.chain(result1 => safeDivide(result1, z))
  );

calculate(20, 2, 5);  // Right(2) - (20/2)/5
calculate(20, 0, 5);  // Left("Cannot divide by zero") - fails at first step
calculate(20, 2, 0);  // Left("Cannot divide by zero") - fails at second step

// map only runs on Right
pipe(
  E.right(5),
  E.map(x => x * 2)  // Right(10)
);

pipe(
  E.left("error"),
  E.map(x => x * 2)  // Left("error") - map doesn't run!
);

Either vs Option:

// Use Option when: presence/absence is enough
const findUser = (id: string): O.Option<User> => {
  // Either user exists or doesn't
};

// Use Either when: you need error details
const validateUser = (data: unknown): E.Either<ValidationError, User> => {
  // Many different ways validation can fail
};

// Converting between them
const optionToEither = <A>(
  option: O.Option<A>,
  onNone: () => string
): E.Either<string, A> =>
  pipe(
    option,
    E.fromOption(onNone)
  );

const user: O.Option<User> = O.none;
const result = optionToEither(user, () => "User not found");
// Left("User not found")

Step 2: Define Error Types

// lib/recipes/errors.ts

/**
 * All possible recipe errors
 * Using discriminated union for type-safe error handling
 */
export type RecipeError =
  | { readonly _tag: 'ValidationError'; readonly message: string }
  | { readonly _tag: 'NotFoundError'; readonly id: string }
  | { readonly _tag: 'DuplicateError'; readonly id: string }
  | { readonly _tag: 'FileSystemError'; readonly message: string };

// Constructor functions
export const validationError = (message: string): RecipeError => ({
  _tag: 'ValidationError',
  message
});

export const notFoundError = (id: string): RecipeError => ({
  _tag: 'NotFoundError',
  id
});

export const duplicateError = (id: string): RecipeError => ({
  _tag: 'DuplicateError',
  id
});

export const fileSystemError = (message: string): RecipeError => ({
  _tag: 'FileSystemError',
  message
});

/**
 * Format error for display
 */
export const formatError = (error: RecipeError): string => {
  switch (error._tag) {
    case 'ValidationError':
      return `Validation Error: ${error.message}`;
    case 'NotFoundError':
      return `Recipe not found: ${error.id}`;
    case 'DuplicateError':
      return `Recipe already exists: ${error.id}`;
    case 'FileSystemError':
      return `File System Error: ${error.message}`;
  }
};

Understanding Discriminated Unions:

// TypeScript can narrow types based on a discriminant field

const handleError = (error: RecipeError): string => {
  switch (error._tag) {
    case 'ValidationError':
      // TypeScript knows: error is { _tag: 'ValidationError', message: string }
      return error.message;  // โœ… Has message property

    case 'NotFoundError':
      // TypeScript knows: error is { _tag: 'NotFoundError', id: string }
      return error.id;  // โœ… Has id property

    // ... other cases
  }
};

// This prevents bugs:
// error.message  // โŒ Error - might not have message
// After checking _tag:
// error.message  // โœ… Safe - TypeScript knows the type

Step 3: Validate Recipes

// lib/recipes/validation.ts
import * as E from 'fp-ts/Either';
import * as A from 'fp-ts/Array';
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
import { Recipe } from '../domain/recipe';
import { RecipeError, validationError } from './errors';

/**
 * Validate a single recipe
 * Returns Left(error) if invalid, Right(recipe) if valid
 */
export const validateRecipe = (recipe: Recipe): E.Either<RecipeError, Recipe> => {
  const errors: string[] = [];

  // Title validation
  if (recipe.title.trim().length === 0) {
    errors.push("Title cannot be empty");
  }
  if (recipe.title.length > 200) {
    errors.push("Title too long (max 200 characters)");
  }

  // Servings validation
  if (recipe.servings <= 0) {
    errors.push("Servings must be positive");
  }
  if (recipe.servings > 1000) {
    errors.push("Servings unrealistic (max 1000)");
  }

  // Time validation
  if (recipe.prepTime < 0 || recipe.cookTime < 0) {
    errors.push("Time cannot be negative");
  }
  if (recipe.prepTime > 1440 || recipe.cookTime > 1440) {
    errors.push("Time too long (max 24 hours)");
  }

  // Ingredients validation
  if (A.isEmpty(recipe.ingredients)) {
    errors.push("Recipe must have at least one ingredient");
  }

  // Steps validation
  if (A.isEmpty(recipe.steps)) {
    errors.push("Recipe must have at least one step");
  }

  return A.isEmpty(errors)
    ? E.right(recipe)
    : E.left(validationError(errors.join("; ")));
};

/**
 * Validate multiple recipes
 */
export const validateRecipes = (
  recipes: readonly Recipe[]
): E.Either<RecipeError, readonly Recipe[]> => {
  // Validate each recipe and collect errors
  const validated = pipe(
    recipes,
    A.map(validateRecipe)
  );

  // Check if any failed
  const firstError = pipe(
    validated,
    A.findFirst(E.isLeft)
  );

  return pipe(
    firstError,
    O.fold(
      // All valid
      () => E.right(recipes),
      // At least one error
      (error) => error
    )
  );
};

// Alternative: Validate and collect ALL errors
export const validateRecipesAll = (
  recipes: readonly Recipe[]
): E.Either<readonly RecipeError[], readonly Recipe[]> => {
  const results = pipe(recipes, A.map(validateRecipe));

  const errors = pipe(
    results,
    A.filterMap(result =>
      E.isLeft(result) ? O.some(result.left) : O.none
    )
  );

  return A.isEmpty(errors)
    ? E.right(recipes)
    : E.left(errors);
};

Step 4: File Operations with TaskEither

Now we need to save recipes to a file. File I/O is async and can fail, so we use TaskEither.

// lib/recipes/storage.ts
import * as TE from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either';
import * as A from 'fp-ts/Array';
import { pipe } from 'fp-ts/function';
import { Recipe } from '../domain/recipe';
import { RecipeError, fileSystemError, notFoundError, duplicateError } from './errors';
import { validateRecipe } from './validation';
import fs from 'fs/promises';
import path from 'path';

const RECIPES_FILE = path.join(process.cwd(), 'data', 'recipes.json');

/**
 * TaskEither<E, A> is an async operation that can fail
 * Like Promise<Either<E, A>>
 */

/**
 * Read all recipes from file
 */
export const readRecipes = (): TE.TaskEither<RecipeError, readonly Recipe[]> =>
  TE.tryCatch(
    async () => {
      try {
        const data = await fs.readFile(RECIPES_FILE, 'utf-8');
        const parsed = JSON.parse(data) as Recipe[];
        // Convert date strings back to Date objects
        return parsed.map(r => ({
          ...r,
          createdAt: new Date(r.createdAt)
        }));
      } catch (error: any) {
        // File doesn't exist yet - return empty array
        if (error.code === 'ENOENT') {
          return [];
        }
        throw error;
      }
    },
    (error) => fileSystemError(`Failed to read recipes: ${error}`)
  );

/**
 * Write recipes to file
 */
const writeRecipes = (
  recipes: readonly Recipe[]
): TE.TaskEither<RecipeError, void> =>
  TE.tryCatch(
    async () => {
      // Ensure directory exists
      await fs.mkdir(path.dirname(RECIPES_FILE), { recursive: true });

      // Convert to JSON
      const json = JSON.stringify(recipes, null, 2);

      // Write file
      await fs.writeFile(RECIPES_FILE, json);
    },
    (error) => fileSystemError(`Failed to write recipes: ${error}`)
  );

/**
 * Save a new recipe
 * Steps:
 * 1. Validate recipe
 * 2. Read existing recipes
 * 3. Check for duplicate
 * 4. Add new recipe
 * 5. Write back to file
 */
export const saveRecipe = (recipe: Recipe): TE.TaskEither<RecipeError, Recipe> =>
  pipe(
    // Step 1: Validate
    TE.fromEither(validateRecipe(recipe)),
    // Step 2: Read existing recipes
    TE.chain(validRecipe =>
      pipe(
        readRecipes(),
        // Step 3: Check for duplicate
        TE.chain(existing => {
          const isDuplicate = pipe(
            existing,
            A.some(r => r.id === validRecipe.id)
          );

          return isDuplicate
            ? TE.left(duplicateError(validRecipe.id))
            : TE.right(validRecipe);
        }),
        // Step 4 & 5: Add and write
        TE.chain(validRecipe2 =>
          pipe(
            readRecipes(),
            TE.chain(existing =>
              pipe(
                writeRecipes([...existing, validRecipe2]),
                TE.map(() => validRecipe2)
              )
            )
          )
        )
      )
    )
  );

/**
 * Get recipe by ID
 */
export const getRecipe = (id: string): TE.TaskEither<RecipeError, Recipe> =>
  pipe(
    readRecipes(),
    TE.chain(recipes =>
      pipe(
        recipes,
        A.findFirst(r => r.id === id),
        TE.fromOption(() => notFoundError(id))
      )
    )
  );

/**
 * Delete recipe by ID
 */
export const deleteRecipe = (id: string): TE.TaskEither<RecipeError, void> =>
  pipe(
    readRecipes(),
    TE.chain(recipes =>
      pipe(
        recipes,
        A.findIndex(r => r.id === id),
        E.fromOption(() => notFoundError(id)),
        TE.fromEither,
        TE.chain(() =>
          pipe(
            recipes,
            A.filter(r => r.id !== id),
            writeRecipes
          )
        )
      )
    )
  );

Understanding TaskEither:

// TaskEither<E, A> = () => Promise<Either<E, A>>
// It's a lazy async operation that can fail

// Create TaskEither from Promise
const fetchUser = (id: number): TE.TaskEither<string, User> =>
  TE.tryCatch(
    () => fetch(`/api/users/${id}`).then(r => r.json()),
    (error) => `Failed to fetch user: ${error}`
  );

// Execute TaskEither (note the double call!)
const result: E.Either<string, User> = await fetchUser(123)();
//                                                             ^ Execute

// Chain async operations
const getUserPosts = (userId: number): TE.TaskEither<string, Post[]> =>
  pipe(
    fetchUser(userId),
    TE.chain(user =>
      TE.tryCatch(
        () => fetch(`/api/users/${user.id}/posts`).then(r => r.json()),
        (error) => `Failed to fetch posts: ${error}`
      )
    )
  );

// Handle both success and failure
pipe(
  getUserPosts(123),
  TE.fold(
    (error) => async () => console.log("Error:", error),
    (posts) => async () => console.log("Posts:", posts)
  )
)();  // Execute

// Convert Either to TaskEither
const eitherValue: E.Either<string, number> = E.right(42);
const taskValue: TE.TaskEither<string, number> = TE.fromEither(eitherValue);

Step 5: API Route to Save Recipe

// app/api/recipes/route.ts
import { NextRequest, NextResponse } from 'next/server';
import * as TE from 'fp-ts/TaskEither';
import { pipe } from 'fp-ts/function';
import { saveRecipe, readRecipes } from '@/lib/recipes/storage';
import { formatError } from '@/lib/recipes/errors';
import { Recipe } from '@/lib/domain/recipe';

/**
 * POST /api/recipes - Create new recipe
 */
export async function POST(request: NextRequest) {
  const recipe: Recipe = await request.json();

  const result = await pipe(
    saveRecipe(recipe),
    TE.fold(
      // Error case
      (error) => async () => NextResponse.json(
        { error: formatError(error) },
        { status: 400 }
      ),
      // Success case
      (savedRecipe) => async () => NextResponse.json(savedRecipe)
    )
  )();

  return result;
}

/**
 * GET /api/recipes - Get all recipes
 */
export async function GET() {
  const result = await pipe(
    readRecipes(),
    TE.fold(
      (error) => async () => NextResponse.json(
        { error: formatError(error) },
        { status: 500 }
      ),
      (recipes) => async () => NextResponse.json(recipes)
    )
  )();

  return result;
}

Step 6: Update Form to Save via API

// app/recipes/create/page.tsx (update handleSubmit)
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    const recipeOption = createRecipe({
      title,
      description,
      servings,
      prepTime,
      cookTime,
    });

    pipe(
      recipeOption,
      O.fold(
        () => {
          setError("Please fill in all required fields with valid values");
        },
        (recipe) => {
          const completeRecipe = {
            ...recipe,
            description: pipe(
              recipe.description,
              O.getOrElse(() => "")
            ),
            ingredients: pipe(
              recipe.ingredients,
              A.map((ingredient) => ({
                name: ingredient.name,
                amount: pipe(
                  ingredient.amount,
                  O.getOrElse(() => 0)
                ),
                unit: pipe(
                  ingredient.unit,
                  O.getOrElse(() => "")
                ),
              }))
            ),
            steps: [], // we will add this feature later
            tags: [], // we will add this feature later
          };

          pipe(
            TE.tryCatch(
              async () =>
                fetch("/api/recipes", {
                  method: "POST",
                  headers: {
                    "Content-Type": "application/json",
                  },
                  body: JSON.stringify(completeRecipe),
                }).then((res) => res),
              (error) => new Error(`Failed to save recipe: ${error}`)
            ),
            TE.fold(
              (err) => async () => setError(err.message),
              (res) => async () => {
                if (res.ok) {
                  const saved = await res.json();
                  alert(`Recipe "${saved.title}" saved successfully!`);
                  // Reset form
                  setTitle("");
                  setDescription("");
                  setServings("4");
                  setPrepTime("15");
                  setCookTime("30");
                  setIngredients([]);
                  setError(null);
                } else {
                  const { error } = await res.json();
                  setError(error);
                }
              }
            )
          )();
        }
      )
    );
  };

โœ… Chapter 3 Summary

What we built: Recipe validation and file storage with error handling

What we learned: - Either type - Represent operations that can fail with error details - Error types - Discriminated unions for type-safe error handling - TaskEither - Async operations that can fail - TE.tryCatch - Safely wrap throwing async code - TE.fold - Handle success and error cases - TE.chain - Sequence async operations

Key insight: Either and TaskEither make error handling explicit and type-safe!


Chapter 4: Listing Recipes - Ord, Filtering & Advanced Arrays

What we're building: Recipe list page with search, filtering, and sorting

fp-ts concepts: Ord, contramap, advanced array operations, function composition

Step 1: Recipe List Page Structure

// app/recipes/page.tsx
'use client';

import { useEffect, useState } from 'react';
import * as A from 'fp-ts/Array';
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
import { Recipe } from '@/lib/domain/recipe';

export default function RecipesPage() {
  const [recipes, setRecipes] = useState<Recipe[]>([]);
  const [loading, setLoading] = useState(true);
  const [searchTerm, setSearchTerm] = useState('');
  const [selectedTag, setSelectedTag] = useState<O.Option<string>>(O.none);
  const [sortBy, setSortBy] = useState<'title' | 'prepTime' | 'recent'>('recent');

  useEffect(() => {
    fetch('/api/recipes')
      .then(r => r.json())
      .then((data: Recipe[]) => {
        setRecipes(data);
        setLoading(false);
      });
  }, []);

  if (loading) {
    return <div className="p-6">Loading recipes...</div>;
  }

  return (
    <div className="max-w-6xl mx-auto p-6">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-3xl font-bold">All Recipes</h1>
        <a 
          href="/recipes/create"
          className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
        >
          + New Recipe
        </a>
      </div>

      <RecipeFilters
        searchTerm={searchTerm}
        setSearchTerm={setSearchTerm}
        selectedTag={selectedTag}
        setSelectedTag={setSelectedTag}
        sortBy={sortBy}
        setSortBy={setSortBy}
        allTags={getAllTags(recipes)}
      />

      <RecipeGrid
        recipes={recipes}
        searchTerm={searchTerm}
        selectedTag={selectedTag}
        sortBy={sortBy}
      />
    </div>
  );
}

Step 2: Understanding Ord - Ordering Types

// lib/core/ord-tutorial.ts
import * as Ord from 'fp-ts/Ord';
import * as S from 'fp-ts/string';
import * as N from 'fp-ts/number';
import * as A from 'fp-ts/Array';
import { pipe } from 'fp-ts/function';

/**
 * Ord defines ordering for a type
 * interface Ord<A> extends Eq<A> {
 *   compare: (x: A, y: A) => Ordering
 * }
 * 
 * Ordering = -1 | 0 | 1
 * -1 means x < y
 *  0 means x = y
 *  1 means x > y
 */

// Built-in Ords
S.Ord.compare("apple", "banana");  // -1 (apple < banana)
S.Ord.compare("banana", "apple");  //  1 (banana > apple)
S.Ord.compare("apple", "apple");   //  0 (equal)

N.Ord.compare(3, 5);  // -1
N.Ord.compare(5, 3);  //  1
N.Ord.compare(5, 5);  //  0

// Sort arrays
const fruits = ["banana", "apple", "cherry", "date"];
pipe(fruits, A.sort(S.Ord));  // ["apple", "banana", "cherry", "date"]

const numbers = [5, 2, 8, 1, 9];
pipe(numbers, A.sort(N.Ord));  // [1, 2, 5, 8, 9]

// Custom Ord for our types
interface Person {
  readonly name: string;
  readonly age: number;
}

// Sort by age
const PersonByAge: Ord.Ord<Person> = pipe(
  N.Ord,
  Ord.contramap((person: Person) => person.age)
);

const people = [
  { name: "Alice", age: 30 },
  { name: "Bob", age: 25 },
  { name: "Charlie", age: 35 }
];

pipe(people, A.sort(PersonByAge));
// [Bob(25), Alice(30), Charlie(35)]

// Sort by name
const PersonByName: Ord.Ord<Person> = pipe(
  S.Ord,
  Ord.contramap((person: Person) => person.name)
);

pipe(people, A.sort(PersonByName));
// [Alice, Bob, Charlie]

// Reverse order
const PersonByAgeDesc = Ord.reverse(PersonByAge);

pipe(people, A.sort(PersonByAgeDesc));
// [Charlie(35), Alice(30), Bob(25)]

Understanding contramap:

// contramap transforms the INPUT before comparing

// We have Ord<number> (compares numbers)
// We want Ord<Person> (compares people)

// Solution: Extract number from Person, then compare
const PersonByAge: Ord.Ord<Person> = pipe(
  N.Ord,  // Ord<number>
  Ord.contramap((p: Person) => p.age)  // Extract age
);
// Now we have Ord<Person>!

// It's like saying:
// "To compare people, first get their ages, then compare those ages"

// Another example
interface Product {
  name: string;
  price: number;
}

const ProductByPrice: Ord.Ord<Product> = pipe(
  N.Ord,
  Ord.contramap((p: Product) => p.price)
);

const products = [
  { name: "Apple", price: 1.5 },
  { name: "Banana", price: 0.5 },
  { name: "Cherry", price: 3.0 }
];

pipe(products, A.sort(ProductByPrice));
// [Banana($0.5), Apple($1.5), Cherry($3.0)]

Step 3: Filtering and Sorting Recipes

// lib/recipes/filters.ts
import * as A from 'fp-ts/Array';
import * as O from 'fp-ts/Option';
import * as Ord from 'fp-ts/Ord';
import * as S from 'fp-ts/string';
import * as N from 'fp-ts/number';
import { pipe } from 'fp-ts/function';
import { Recipe } from '../domain/recipe';

/**
 * Get all unique tags from recipes, sorted
 */
export const getAllTags = (recipes: readonly Recipe[]): readonly string[] =>
  pipe(
    recipes,
    A.chain(recipe => recipe.tags),  // Flatten all tags
    A.uniq(S.Eq),                     // Remove duplicates
    A.sort(S.Ord)                     // Sort alphabetically
  );

/**
 * Filter recipes by search term (title or description)
 */
export const filterBySearch = (searchTerm: string) =>
  (recipes: readonly Recipe[]): readonly Recipe[] => {
    if (searchTerm.trim() === '') return recipes;

    const term = searchTerm.toLowerCase();

    return pipe(
      recipes,
      A.filter(recipe =>
        recipe.title.toLowerCase().includes(term) ||
        pipe(
          recipe.description,
          O.fold(
            () => false,
            desc => desc.toLowerCase().includes(term)
          )
        )
      )
    );
  };

/**
 * Filter recipes by tag
 */
export const filterByTag = (tag: O.Option<string>) =>
  (recipes: readonly Recipe[]): readonly Recipe[] =>
    pipe(
      tag,
      O.fold(
        () => recipes,  // No tag selected - return all
        selectedTag => pipe(
          recipes,
          A.filter(recipe =>
            pipe(
              recipe.tags,
              A.elem(S.Eq)(selectedTag)
            )
          )
        )
      )
    );

/**
 * Sort recipes by different criteria
 */
export const sortRecipes = (sortBy: 'title' | 'prepTime' | 'recent') =>
  (recipes: readonly Recipe[]): readonly Recipe[] => {
    switch (sortBy) {
      case 'title':
        return pipe(
          recipes,
          A.sort(pipe(
            S.Ord,
            Ord.contramap((r: Recipe) => r.title)
          ))
        );

      case 'prepTime':
        return pipe(
          recipes,
          A.sort(pipe(
            N.Ord,
            Ord.contramap((r: Recipe) => r.prepTime)
          ))
        );

      case 'recent':
        return pipe(
          recipes,
          A.sort(pipe(
            N.Ord,
            Ord.contramap((r: Recipe) => r.createdAt.getTime())
          )),
          A.reverse  // Newest first
        );
    }
  };

/**
 * Combine all filters - this is function composition!
 */
export const applyFilters = (
  searchTerm: string,
  tag: O.Option<string>,
  sortBy: 'title' | 'prepTime' | 'recent'
) =>
  (recipes: readonly Recipe[]): readonly Recipe[] =>
    pipe(
      recipes,
      filterBySearch(searchTerm),
      filterByTag(tag),
      sortRecipes(sortBy)
    );

Understanding Function Composition:

// Function composition: combine small functions into bigger ones

const addTax = (price: number) => price * 1.1;
const applyDiscount = (price: number) => price * 0.9;
const roundToTwo = (price: number) => Math.round(price * 100) / 100;

// โŒ Without composition - nested
const final1 = roundToTwo(addTax(applyDiscount(100)));

// โœ… With pipe - clear flow
const final2 = pipe(
  100,
  applyDiscount,  // 90
  addTax,         // 99
  roundToTwo      // 99
);

// โœ… With flow - create reusable function
const calculateFinalPrice = flow(
  applyDiscount,
  addTax,
  roundToTwo
);

calculateFinalPrice(100);  // 99
calculateFinalPrice(200);  // 198

// Our filters use the same pattern!
const processRecipes = pipe(
  recipes,
  filterBySearch("pasta"),  // Get pasta recipes
  filterByTag("italian"),   // Only Italian
  sortRecipes("prepTime")   // Sort by prep time
);

Step 4: Recipe Grid Component

// components/RecipeGrid.tsx
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
import { Recipe } from '@/lib/domain/recipe';
import { applyFilters } from '@/lib/recipes/filters';
import { RecipeCard } from './RecipeCard';

interface Props {
  recipes: readonly Recipe[];
  searchTerm: string;
  selectedTag: O.Option<string>;
  sortBy: 'title' | 'prepTime' | 'recent';
}

export function RecipeGrid({ recipes, searchTerm, selectedTag, sortBy }: Props) {
  const filteredRecipes = pipe(
    recipes,
    applyFilters(searchTerm, selectedTag, sortBy)
  );

  if (filteredRecipes.length === 0) {
    return (
      <div className="text-center py-12">
        <p className="text-gray-500 text-lg">No recipes found</p>
        <p className="text-gray-400 text-sm mt-2">
          Try adjusting your search or filters
        </p>
      </div>
    );
  }

  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
      {filteredRecipes.map(recipe => (
        <RecipeCard key={recipe.id} recipe={recipe} />
      ))}
    </div>
  );
}
// components/RecipeCard.tsx
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
import Link from 'next/link';
import { Recipe } from '@/lib/domain/recipe';

interface Props {
  recipe: Recipe;
}

export function RecipeCard({ recipe }: Props) {
  const totalTime = recipe.prepTime + recipe.cookTime;

  return (
    <Link href={`/recipes/${recipe.id}`}>
      <div className="border rounded-lg p-4 hover:shadow-lg transition-shadow cursor-pointer h-full flex flex-col">
        <h3 className="font-bold text-xl mb-2 line-clamp-2">
          {recipe.title}
        </h3>

        {pipe(
          recipe.description,
          O.fold(
            () => null,
            desc => (
              <p className="text-gray-600 text-sm mb-3 line-clamp-2 flex-grow">
                {desc}
              </p>
            )
          )
        )}

        <div className="flex gap-4 text-sm text-gray-500 mb-3">
          <span className="flex items-center gap-1">
            ๐Ÿฝ๏ธ {recipe.servings} servings
          </span>
          <span className="flex items-center gap-1">
            โฑ๏ธ {totalTime} min
          </span>
        </div>

        <div className="flex flex-wrap gap-2">
          {recipe.tags.slice(0, 3).map(tag => (
            <span
              key={tag}
              className="bg-blue-100 text-blue-700 px-2 py-1 rounded text-xs"
            >
              {tag}
            </span>
          ))}
          {recipe.tags.length > 3 && (
            <span className="text-gray-500 text-xs py-1">
              +{recipe.tags.length - 3} more
            </span>
          )}
        </div>
      </div>
    </Link>
  );
}

Step 5: Filters Component

// components/RecipeFilters.tsx
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';

interface Props {
  searchTerm: string;
  setSearchTerm: (term: string) => void;
  selectedTag: O.Option<string>;
  setSelectedTag: (tag: O.Option<string>) => void;
  sortBy: 'title' | 'prepTime' | 'recent';
  setSortBy: (sort: 'title' | 'prepTime' | 'recent') => void;
  allTags: readonly string[];
}

export function RecipeFilters({
  searchTerm,
  setSearchTerm,
  selectedTag,
  setSelectedTag,
  sortBy,
  setSortBy,
  allTags
}: Props) {
  return (
    <div className="bg-white border rounded-lg p-4 mb-6 space-y-4">
      {/* Search */}
      <div>
        <label className="block text-sm font-medium mb-2">Search</label>
        <input
          type="text"
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
          placeholder="Search recipes..."
          className="w-full border rounded px-3 py-2"
        />
      </div>

      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
        {/* Tag Filter */}
        <div>
          <label className="block text-sm font-medium mb-2">Filter by Tag</label>
          <select
            value={pipe(
              selectedTag,
              O.fold(() => '', tag => tag)
            )}
            onChange={(e) =>
              setSelectedTag(
                e.target.value ? O.some(e.target.value) : O.none
              )
            }
            className="w-full border rounded px-3 py-2"
          >
            <option value="">All tags</option>
            {allTags.map(tag => (
              <option key={tag} value={tag}>{tag}</option>
            ))}
          </select>
        </div>

        {/* Sort */}
        <div>
          <label className="block text-sm font-medium mb-2">Sort by</label>
          <select
            value={sortBy}
            onChange={(e) => setSortBy(e.target.value as any)}
            className="w-full border rounded px-3 py-2"
          >
            <option value="recent">Most Recent</option>
            <option value="title">Title (A-Z)</option>
            <option value="prepTime">Prep Time</option>
          </select>
        </div>
      </div>
    </div>
  );
}

โœ… Chapter 4 Summary

What we built: Recipe list with search, filter, and sort

What we learned: - Ord type class - Define ordering for custom types - Ord.contramap - Sort by extracted property - A.chain - Flatten arrays (flatMap) - A.elem - Check if element exists - Function composition - Combine filters with pipe - flow - Create reusable composed functions

Key insight: Type classes (Eq, Ord) let us define behavior for our types in a composable way!


Chapter 5: Reader Pattern - Dependency Injection

What we're building: Recipe detail page with configurable formatting

fp-ts concepts: Reader, ReaderTaskEither, dependency injection

Why Reader?

Traditional approach to configuration: pass it to every function ๐Ÿ˜ซ

// โŒ Config passed everywhere
function formatTime(minutes: number, config: Config): string { ... }
function formatDate(date: Date, config: Config): string { ... }
function formatIngredient(ing: Ingredient, config: Config): string { ... }

// Every caller needs config!
const time = formatTime(30, config);
const date = formatDate(new Date(), config);
const ing = formatIngredient(flour, config);

Reader solves this: inject dependencies once! ๐ŸŽ‰

// โœ… Reader approach
const formatTime: R.Reader<Config, (minutes: number) => string> = ...
const formatDate: R.Reader<Config, (date: Date) => string> = ...

// Provide config once
const timeFormatter = formatTime(config);
const dateFormatter = formatDate(config);

// Use formatters without config
const time = timeFormatter(30);
const date = dateFormatter(new Date());

Step 1: Understanding Reader

// lib/core/reader-tutorial.ts
import * as R from 'fp-ts/Reader';
import { pipe } from 'fp-ts/function';

/**
 * Reader<R, A> is just a function: (r: R) => A
 * It reads from environment R and produces value A
 */

// Example: Configuration-dependent functions
interface Config {
  readonly locale: string;
  readonly currency: string;
  readonly taxRate: number;
}

// Reader that needs Config
const getTaxRate: R.Reader<Config, number> =
  (config) => config.taxRate;

// Use the Reader
const config: Config = {
  locale: 'en-US',
  currency: 'USD',
  taxRate: 0.1
};

const taxRate = getTaxRate(config);  // 0.1

// map transforms the output
const getTaxRatePercent: R.Reader<Config, string> = pipe(
  getTaxRate,
  R.map(rate => `${rate * 100}%`)
);

getTaxRatePercent(config);  // "10%"

// Combining multiple Readers
const formatPrice: R.Reader<Config, (price: number) => string> = pipe(
  R.Do,
  R.apS('locale', R.asks((c: Config) => c.locale)),
  R.apS('currency', R.asks((c: Config) => c.currency)),
  R.apS('taxRate', getTaxRate),
  R.map(({ locale, currency, taxRate }) => 
    (price: number) => {
      const withTax = price * (1 + taxRate);
      return new Intl.NumberFormat(locale, {
        style: 'currency',
        currency
      }).format(withTax);
    }
  )
);

const pricer = formatPrice(config);
pricer(100);  // "$110.00"

Reader Benefits:

// Without Reader - config everywhere
function calculateTotal(items: Item[], config: Config): number {
  return items.reduce((total, item) => 
    total + calculateItemPrice(item, config), 0
  );
}

function calculateItemPrice(item: Item, config: Config): number {
  const price = getPrice(item, config);
  return applyTax(price, config);
}

// With Reader - config injected once
const calculateItemPrice: R.Reader<Config, (item: Item) => number> = pipe(
  R.Do,
  R.bind('getPrice', () => getPrice),
  R.bind('applyTax', () => applyTax),
  R.map(({ getPrice, applyTax }) => 
    (item: Item) => applyTax(getPrice(item))
  )
);

const calculateTotal: R.Reader<Config, (items: Item[]) => number> = pipe(
  calculateItemPrice,
  R.map(calcItem => (items: Item[]) =>
    items.reduce((total, item) => total + calcItem(item), 0)
  )
);

// Execute with config once
const totalCalc = calculateTotal(config);
const total = totalCalc(items);

Step 2: Define Display Configuration

// lib/domain/config.ts

export type TimeUnit = 'minutes' | 'hours';
export type MeasurementSystem = 'metric' | 'imperial';

export interface DisplayConfig {
  readonly locale: string;
  readonly timeUnit: TimeUnit;
  readonly measurementSystem: MeasurementSystem;
  readonly dateFormat: string;
}

export const defaultConfig: DisplayConfig = {
  locale: 'en-US',
  timeUnit: 'minutes',
  measurementSystem: 'imperial',
  dateFormat: 'MMM d, yyyy'
};

// Different presets
export const configs = {
  us: defaultConfig,
  uk: {
    ...defaultConfig,
    locale: 'en-GB',
    measurementSystem: 'metric' as const
  },
  europe: {
    ...defaultConfig,
    locale: 'de-DE',
    measurementSystem: 'metric' as const,
    dateFormat: 'dd.MM.yyyy'
  }
};

Step 3: Create Reader-based Formatters

// lib/recipes/formatters.ts
import * as R from 'fp-ts/Reader';
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
import { format } from 'date-fns';
import { DisplayConfig } from '../domain/config';
import { Ingredient } from '../domain/recipe';

/**
 * Format time duration based on config
 */
export const formatTime: R.Reader<DisplayConfig, (minutes: number) => string> =
  (config) => (minutes) => {
    if (config.timeUnit === 'hours' && minutes >= 60) {
      const hours = Math.floor(minutes / 60);
      const mins = minutes % 60;
      return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
    }
    return `${minutes} min`;
  };

/**
 * Format date based on config
 */
export const formatDate: R.Reader<DisplayConfig, (date: Date) => string> =
  (config) => (date) =>
    format(date, config.dateFormat);

/**
 * Convert units based on measurement system
 */
interface ConvertedAmount {
  readonly amount: number;
  readonly unit: string;
}

const convertUnit = (
  unit: string,
  amount: number,
  system: MeasurementSystem
): ConvertedAmount => {
  const conversions: Record<string, Record<string, [number, string]>> = {
    metric: {
      'oz': [28.35, 'g'],
      'lb': [453.59, 'g'],
      'cup': [236.59, 'ml'],
      'tbsp': [14.79, 'ml'],
      'tsp': [4.93, 'ml']
    },
    imperial: {
      'g': [0.035, 'oz'],
      'kg': [2.205, 'lb'],
      'ml': [0.004, 'cup'],
      'l': [4.227, 'cup']
    }
  };

  const conversion = conversions[system][unit.toLowerCase()];
  if (conversion) {
    const [factor, newUnit] = conversion;
    return {
      amount: Math.round(amount * factor * 100) / 100,
      unit: newUnit
    };
  }

  return { amount, unit };
};

/**
 * Format ingredient with scaling and unit conversion
 */
export const formatIngredient: R.Reader<
  DisplayConfig,
  (ing: Ingredient, scale: number) => string
> = (config) => (ing, scale) => {
  return pipe(
    ing.amount,
    O.fold(
      () => ing.name,
      (amount) => {
        const scaled = amount * scale;
        return pipe(
          ing.unit,
          O.fold(
            () => `${scaled} ${ing.name}`,
            (unit) => {
              const converted = convertUnit(unit, scaled, config.measurementSystem);
              return `${converted.amount} ${converted.unit} ${ing.name}`;
            }
          )
        );
      }
    )
  );
};

/**
 * Format total cooking time
 */
export const formatTotalTime: R.Reader<
  DisplayConfig,
  (prep: number, cook: number) => string
> = pipe(
  formatTime,
  R.map(fmt => (prep: number, cook: number) => {
    const total = prep + cook;
    return `Prep: ${fmt(prep)}, Cook: ${fmt(cook)}, Total: ${fmt(total)}`;
  })
);

/**
 * Get all formatters at once
 */
export interface Formatters {
  readonly formatTime: (minutes: number) => string;
  readonly formatDate: (date: Date) => string;
  readonly formatIngredient: (ing: Ingredient, scale: number) => string;
  readonly formatTotalTime: (prep: number, cook: number) => string;
}

export const getFormatters: R.Reader<DisplayConfig, Formatters> = pipe(
  R.Do,
  R.apS('formatTime', formatTime),
  R.apS('formatDate', formatDate),
  R.apS('formatIngredient', formatIngredient),
  R.apS('formatTotalTime', formatTotalTime)
);

Step 4: Recipe Detail Page with Reader

// app/recipes/[id]/page.tsx
'use client';

import { useEffect, useState } from 'react';
import * as O from 'fp-ts/Option';
import * as A from 'fp-ts/Array';
import { pipe } from 'fp-ts/function';
import { Recipe } from '@/lib/domain/recipe';
import { DisplayConfig, defaultConfig } from '@/lib/domain/config';
import { getFormatters } from '@/lib/recipes/formatters';

export default function RecipeDetailPage({ 
  params 
}: { 
  params: { id: string } 
}) {
  const [recipe, setRecipe] = useState<O.Option<Recipe>>(O.none);
  const [config, setConfig] = useState<DisplayConfig>(defaultConfig);
  const [servingScale, setServingScale] = useState(1);
  const [loading, setLoading] = useState(true);

  // Get formatters from config using Reader
  const formatters = getFormatters(config);

  useEffect(() => {
    fetch('/api/recipes')
      .then(r => r.json())
      .then((recipes: Recipe[]) => {
        const found = pipe(
          recipes,
          A.findFirst(r => r.id === params.id)
        );
        setRecipe(found);
        setLoading(false);
      });
  }, [params.id]);

  if (loading) {
    return <div className="p-6">Loading...</div>;
  }

  return pipe(
    recipe,
    O.fold(
      () => (
        <div className="p-6">
          <p className="text-red-600">Recipe not found</p>
        </div>
      ),
      (r) => (
        <div className="max-w-4xl mx-auto p-6">
          <h1 className="text-4xl font-bold mb-4">{r.title}</h1>

          {pipe(
            r.description,
            O.fold(
              () => null,
              desc => (
                <p className="text-gray-700 text-lg mb-6">{desc}</p>
              )
            )
          )}

          <div className="bg-gray-50 p-6 rounded-lg mb-6">
            <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
              <div>
                <strong className="text-gray-700">Servings:</strong>
                <div className="flex items-center gap-2 mt-1">
                  <input
                    type="number"
                    value={Math.round(r.servings * servingScale)}
                    onChange={(e) => setServingScale(
                      parseInt(e.target.value) / r.servings
                    )}
                    className="w-20 border rounded px-3 py-1"
                    min="1"
                  />
                  {servingScale !== 1 && (
                    <span className="text-sm text-gray-500">
                      (scaled {servingScale}x)
                    </span>
                  )}
                </div>
              </div>

              <div>
                <strong className="text-gray-700">Time:</strong>
                <p className="mt-1">
                  {formatters.formatTotalTime(r.prepTime, r.cookTime)}
                </p>
              </div>

              <div>
                <strong className="text-gray-700">Created:</strong>
                <p className="mt-1">{formatters.formatDate(r.createdAt)}</p>
              </div>

              <div>
                <strong className="text-gray-700">Tags:</strong>
                <div className="flex flex-wrap gap-2 mt-1">
                  {pipe(
                    r.tags,
                    A.map(tag => (
                      <span
                        key={tag}
                        className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-sm"
                      >
                        {tag}
                      </span>
                    ))
                  )}
                </div>
              </div>
            </div>
          </div>

          <div className="mb-6">
            <h2 className="text-2xl font-bold mb-4">Ingredients</h2>
            <ul className="space-y-2">
              {pipe(
                r.ingredients,
                A.mapWithIndex((index, ing) => (
                  <li
                    key={index}
                    className="flex items-center gap-2 bg-white border rounded p-3"
                  >
                    <span className="text-blue-600">โ€ข</span>
                    <span>{formatters.formatIngredient(ing, servingScale)}</span>
                  </li>
                ))
              )}
            </ul>
          </div>

          {!A.isEmpty(r.steps) && (
            <div className="mb-6">
              <h2 className="text-2xl font-bold mb-4">Steps</h2>
              <ol className="space-y-3">
                {pipe(
                  r.steps,
                  A.mapWithIndex((index, step) => (
                    <li key={index} className="flex gap-3">
                      <span className="bg-blue-600 text-white rounded-full w-8 h-8 flex items-center justify-center flex-shrink-0 font-bold">
                        {index + 1}
                      </span>
                      <p className="pt-1">{step}</p>
                    </li>
                  ))
                )}
              </ol>
            </div>
          )}

          <ConfigPanel config={config} setConfig={setConfig} />
        </div>
      )
    )
  );
}

Step 5: Configuration Panel

// components/ConfigPanel.tsx
import { DisplayConfig, TimeUnit, MeasurementSystem } from '@/lib/domain/config';

interface Props {
  config: DisplayConfig;
  setConfig: (config: DisplayConfig) => void;
}

export function ConfigPanel({ config, setConfig }: Props) {
  return (
    <div className="border-t pt-6 mt-6">
      <h3 className="font-bold text-lg mb-4">Display Settings</h3>

      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
        <div>
          <label className="block text-sm font-medium mb-2">
            Time Unit
          </label>
          <select
            value={config.timeUnit}
            onChange={(e) => setConfig({
              ...config,
              timeUnit: e.target.value as TimeUnit
            })}
            className="w-full border rounded px-3 py-2"
          >
            <option value="minutes">Minutes</option>
            <option value="hours">Hours & Minutes</option>
          </select>
        </div>

        <div>
          <label className="block text-sm font-medium mb-2">
            Measurement System
          </label>
          <select
            value={config.measurementSystem}
            onChange={(e) => setConfig({
              ...config,
              measurementSystem: e.target.value as MeasurementSystem
            })}
            className="w-full border rounded px-3 py-2"
          >
            <option value="imperial">Imperial (cups, oz)</option>
            <option value="metric">Metric (g, ml)</option>
          </select>
        </div>
      </div>
    </div>
  );
}

โœ… Chapter 5 Summary

What we built: Recipe detail page with configurable formatting

What we learned: - Reader monad - Dependency injection pattern - R.map - Transform Reader's output - R.Do notation - Combine multiple Readers - Configuration pattern - Clean dependency management - Unit conversion - Practical use of Reader

Key insight: Reader eliminates passing config to every function!


Chapter 6: Semigroup & Monoid - Combining Data

What we're building: Recipe statistics, merging recipes, and data aggregation

fp-ts concepts: Semigroup, Monoid, data combination patterns

Why Semigroup and Monoid?

We often need to combine things: - Merge two recipes - Calculate total statistics - Combine search results - Aggregate user preferences

Semigroup and Monoid give us a principled way to combine data.

Step 1: Understanding Semigroup

// lib/core/semigroup-tutorial.ts
import * as S from 'fp-ts/Semigroup';
import * as N from 'fp-ts/number';
import * as Str from 'fp-ts/string';
import * as A from 'fp-ts/Array';
import { pipe } from 'fp-ts/function';

/**
 * Semigroup: anything with an associative concat operation
 * 
 * interface Semigroup<A> {
 *   concat: (x: A, y: A) => A
 * }
 * 
 * Law: concat(a, concat(b, c)) === concat(concat(a, b), c)
 */

// Built-in Semigroups

// Numbers - addition
N.SemigroupSum.concat(5, 3);  // 8
N.SemigroupSum.concat(1, N.SemigroupSum.concat(2, 3));  // 6
N.SemigroupSum.concat(N.SemigroupSum.concat(1, 2), 3);  // 6

// Numbers - multiplication
N.SemigroupProduct.concat(5, 3);  // 15

// Strings - concatenation
Str.Semigroup.concat("Hello, ", "World!");  // "Hello, World!"

// Arrays - concatenation
const arraySemi = A.getSemigroup<number>();
arraySemi.concat([1, 2], [3, 4]);  // [1, 2, 3, 4]

// Custom Semigroup for our types
interface RecipeStats {
  readonly totalRecipes: number;
  readonly totalCookTime: number;
  readonly totalPrepTime: number;
}

const RecipeStatsSemigroup: S.Semigroup<RecipeStats> = {
  concat: (a, b) => ({
    totalRecipes: a.totalRecipes + b.totalRecipes,
    totalCookTime: a.totalCookTime + b.totalCookTime,
    totalPrepTime: a.totalPrepTime + b.totalPrepTime
  })
};

const stats1: RecipeStats = {
  totalRecipes: 5,
  totalCookTime: 120,
  totalPrepTime: 60
};

const stats2: RecipeStats = {
  totalRecipes: 3,
  totalCookTime: 80,
  totalPrepTime: 40
};

RecipeStatsSemigroup.concat(stats1, stats2);
// { totalRecipes: 8, totalCookTime: 200, totalPrepTime: 100 }

// Associativity matters for combining multiple values
const stats3: RecipeStats = {
  totalRecipes: 2,
  totalCookTime: 50,
  totalPrepTime: 30
};

const way1 = RecipeStatsSemigroup.concat(
  stats1,
  RecipeStatsSemigroup.concat(stats2, stats3)
);

const way2 = RecipeStatsSemigroup.concat(
  RecipeStatsSemigroup.concat(stats1, stats2),
  stats3
);

// Both give same result!
// { totalRecipes: 10, totalCookTime: 250, totalPrepTime: 130 }

Step 2: Understanding Monoid

// lib/core/monoid-tutorial.ts
import * as M from 'fp-ts/Monoid';
import * as S from 'fp-ts/Semigroup';
import * as N from 'fp-ts/number';
import * as Str from 'fp-ts/string';
import * as A from 'fp-ts/Array';
import { pipe } from 'fp-ts/function';

/**
 * Monoid: Semigroup with an identity element (empty value)
 * 
 * interface Monoid<A> extends Semigroup<A> {
 *   empty: A
 * }
 * 
 * Laws:
 * - concat(empty, a) === a  (left identity)
 * - concat(a, empty) === a  (right identity)
 * - concat is associative (from Semigroup)
 */

// Built-in Monoids

// Numbers - sum
M.MonoidSum.empty;  // 0
M.MonoidSum.concat(0, 5);  // 5
M.MonoidSum.concat(5, 0);  // 5

// Numbers - product
M.MonoidProduct.empty;  // 1
M.MonoidProduct.concat(1, 5);  // 5
M.MonoidProduct.concat(5, 1);  // 5

// Strings
Str.Monoid.empty;  // ""
Str.Monoid.concat("", "hello");  // "hello"
Str.Monoid.concat("hello", "");  // "hello"

// Arrays
const arrayMonoid = A.getMonoid<number>();
arrayMonoid.empty;  // []
arrayMonoid.concat([], [1, 2]);  // [1, 2]
arrayMonoid.concat([1, 2], []);  // [1, 2]

// Custom Monoid
const RecipeStatsMonoid: M.Monoid<RecipeStats> = {
  concat: RecipeStatsSemigroup.concat,
  empty: {
    totalRecipes: 0,
    totalCookTime: 0,
    totalPrepTime: 0
  }
};

// Now we can reduce arrays!
const allStats = [stats1, stats2, stats3];
const total = M.concatAll(RecipeStatsMonoid)(allStats);
// { totalRecipes: 10, totalCookTime: 250, totalPrepTime: 130 }

// Works even with empty arrays!
M.concatAll(RecipeStatsMonoid)([]);
// { totalRecipes: 0, totalCookTime: 0, totalPrepTime: 0 }

Why Monoid Matters:

// Without Monoid - manual reduction
const calculateTotalManual = (statsList: RecipeStats[]): RecipeStats => {
  if (statsList.length === 0) {
    return { totalRecipes: 0, totalCookTime: 0, totalPrepTime: 0 };
  }
  return statsList.reduce((acc, stats) => ({
    totalRecipes: acc.totalRecipes + stats.totalRecipes,
    totalCookTime: acc.totalCookTime + stats.totalCookTime,
    totalPrepTime: acc.totalPrepTime + stats.totalPrepTime
  }));
};

// With Monoid - one line!
const calculateTotalFP = M.concatAll(RecipeStatsMonoid);

// Much more composable
pipe(
  recipes,
  A.map(recipeToStats),  // Convert each recipe to stats
  M.concatAll(RecipeStatsMonoid)  // Combine all stats
);

Step 3: Recipe Statistics

// lib/recipes/stats.ts
import * as M from 'fp-ts/Monoid';
import * as S from 'fp-ts/Semigroup';
import * as A from 'fp-ts/Array';
import { pipe } from 'fp-ts/function';
import { Recipe } from '../domain/recipe';

export interface RecipeStatistics {
  readonly count: number;
  readonly totalPrepTime: number;
  readonly totalCookTime: number;
  readonly avgPrepTime: number;
  readonly avgCookTime: number;
  readonly totalServings: number;
  readonly uniqueTags: Set<string>;
  readonly uniqueIngredients: Set<string>;
}

// Internal aggregation type
interface StatsAgg {
  readonly count: number;
  readonly totalPrepTime: number;
  readonly totalCookTime: number;
  readonly totalServings: number;
  readonly tags: readonly string[];
  readonly ingredients: readonly string[];
}

// Semigroup for arrays (concatenation)
const strArraySemi = A.getSemigroup<string>();

// Monoid for our aggregation type
const StatsAggMonoid: M.Monoid<StatsAgg> = {
  empty: {
    count: 0,
    totalPrepTime: 0,
    totalCookTime: 0,
    totalServings: 0,
    tags: [],
    ingredients: []
  },
  concat: (a, b) => ({
    count: a.count + b.count,
    totalPrepTime: a.totalPrepTime + b.totalPrepTime,
    totalCookTime: a.totalCookTime + b.totalCookTime,
    totalServings: a.totalServings + b.totalServings,
    tags: strArraySemi.concat(a.tags, b.tags),
    ingredients: strArraySemi.concat(a.ingredients, b.ingredients)
  })
};

/**
 * Convert Recipe to StatsAgg
 */
const recipeToStats = (recipe: Recipe): StatsAgg => ({
  count: 1,
  totalPrepTime: recipe.prepTime,
  totalCookTime: recipe.cookTime,
  totalServings: recipe.servings,
  tags: recipe.tags,
  ingredients: pipe(
    recipe.ingredients,
    A.map(ing => ing.name)
  )
});

/**
 * Calculate statistics for recipes
 */
export const calculateStatistics = (
  recipes: readonly Recipe[]
): RecipeStatistics => {
  const agg = pipe(
    recipes,
    A.map(recipeToStats),
    M.concatAll(StatsAggMonoid)
  );

  return {
    count: agg.count,
    totalPrepTime: agg.totalPrepTime,
    totalCookTime: agg.totalCookTime,
    avgPrepTime: agg.count > 0 ? agg.totalPrepTime / agg.count : 0,
    avgCookTime: agg.count > 0 ? agg.totalCookTime / agg.count : 0,
    totalServings: agg.totalServings,
    uniqueTags: new Set(agg.tags),
    uniqueIngredients: new Set(agg.ingredients)
  };
};

Step 4: Merging Recipes with Semigroup

// lib/recipes/merge.ts
import * as S from 'fp-ts/Semigroup';
import * as A from 'fp-ts/Array';
import * as O from 'fp-ts/Option';
import * as Str from 'fp-ts/string';
import { pipe } from 'fp-ts/function';
import { Recipe, Ingredient } from '../domain/recipe';

/**
 * Semigroup for Option - prefers Some over None
 */
const optionSemigroup = <A>(semigroup: S.Semigroup<A>): S.Semigroup<O.Option<A>> => ({
  concat: (a, b) =>
    pipe(
      a,
      O.fold(
        () => b,
        aValue => pipe(
          b,
          O.fold(
            () => a,
            bValue => O.some(semigroup.concat(aValue, bValue))
          )
        )
      )
    )
});

/**
 * Merge ingredients by name
 */
const mergeIngredients = (
  a: readonly Ingredient[],
  b: readonly Ingredient[]
): readonly Ingredient[] => {
  const result: Ingredient[] = [...a];

  for (const bIng of b) {
    const existingIndex = pipe(
      result,
      A.findIndex(aIng => aIng.name === bIng.name)
    );

    if (existingIndex >= 0) {
      // Combine amounts
      const existing = result[existingIndex];
      const combined = {
        ...existing,
        amount: pipe(
          existing.amount,
          O.fold(
            () => bIng.amount,
            aAmt => pipe(
              bIng.amount,
              O.fold(
                () => existing.amount,
                bAmt => O.some(aAmt + bAmt)
              )
            )
          )
        )
      };
      result[existingIndex] = combined;
    } else {
      result.push(bIng);
    }
  }

  return result;
};

/**
 * Semigroup for Recipe - combines two recipes
 */
export const RecipeSemigroup: S.Semigroup<Recipe> = {
  concat: (a, b) => ({
    id: a.id,  // Keep first ID
    title: `${a.title} & ${b.title}`,
    description: pipe(
      optionSemigroup(Str.Semigroup).concat(
        a.description,
        b.description
      ),
      O.map(desc => `${desc}`)
    ),
    servings: a.servings + b.servings,
    prepTime: Math.max(a.prepTime, b.prepTime),  // Parallel
    cookTime: a.cookTime + b.cookTime,  // Sequential
    ingredients: mergeIngredients(a.ingredients, b.ingredients),
    steps: [...a.steps, ...b.steps],
    tags: pipe(
      [...a.tags, ...b.tags],
      A.uniq(Str.Eq)
    ),
    createdAt: a.createdAt  // Keep first date
  })
};

/**
 * Merge multiple recipes
 */
export const mergeRecipes = (recipes: readonly Recipe[]): O.Option<Recipe> =>
  pipe(
    recipes,
    A.head,
    O.map(first =>
      pipe(
        recipes,
        A.tail,
        O.fold(
          () => first,
          rest => rest.reduce(RecipeSemigroup.concat, first)
        )
      )
    )
  );

Step 5: Statistics Dashboard

// app/stats/page.tsx
'use client';

import { useEffect, useState } from 'react';
import { Recipe } from '@/lib/domain/recipe';
import { calculateStatistics, RecipeStatistics } from '@/lib/recipes/stats';

export default function StatsPage() {
  const [stats, setStats] = useState<RecipeStatistics | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/recipes')
      .then(r => r.json())
      .then((recipes: Recipe[]) => {
        setStats(calculateStatistics(recipes));
        setLoading(false);
      });
  }, []);

  if (loading) {
    return <div className="p-6">Loading statistics...</div>;
  }

  if (!stats) {
    return <div className="p-6">No data available</div>;
  }

  return (
    <div className="max-w-6xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">Recipe Statistics</h1>

      <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
        <StatCard
          title="Total Recipes"
          value={stats.count}
          icon="๐Ÿ“š"
        />
        <StatCard
          title="Avg Prep Time"
          value={`${Math.round(stats.avgPrepTime)} min`}
          icon="โฑ๏ธ"
        />
        <StatCard
          title="Avg Cook Time"
          value={`${Math.round(stats.avgCookTime)} min`}
          icon="๐Ÿ”ฅ"
        />
        <StatCard
          title="Total Servings"
          value={stats.totalServings}
          icon="๐Ÿฝ๏ธ"
        />
      </div>

      <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
        <div className="bg-white border rounded-lg p-6">
          <h2 className="font-bold text-xl mb-4">Popular Tags</h2>
          {stats.uniqueTags.size > 0 ? (
            <div className="flex flex-wrap gap-2">
              {Array.from(stats.uniqueTags)
                .sort()
                .map(tag => (
                  <span
                    key={tag}
                    className="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm"
                  >
                    {tag}
                  </span>
                ))}
            </div>
          ) : (
            <p className="text-gray-500">No tags yet</p>
          )}
        </div>

        <div className="bg-white border rounded-lg p-6">
          <h2 className="font-bold text-xl mb-4">Ingredient Diversity</h2>
          <div className="text-center">
            <div className="text-5xl font-bold text-green-600 mb-2">
              {stats.uniqueIngredients.size}
            </div>
            <p className="text-gray-600">unique ingredients used</p>
          </div>
        </div>
      </div>

      <div className="mt-6 bg-gray-50 border rounded-lg p-6">
        <h2 className="font-bold text-xl mb-4">Total Time Invested</h2>
        <div className="grid grid-cols-2 gap-4">
          <div>
            <div className="text-sm text-gray-600 mb-1">Prep Time</div>
            <div className="text-2xl font-bold text-blue-600">
              {Math.round(stats.totalPrepTime / 60)}h {stats.totalPrepTime % 60}m
            </div>
          </div>
          <div>
            <div className="text-sm text-gray-600 mb-1">Cook Time</div>
            <div className="text-2xl font-bold text-orange-600">
              {Math.round(stats.totalCookTime / 60)}h {stats.totalCookTime % 60}m
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

function StatCard({ 
  title, 
  value, 
  icon 
}: { 
  title: string; 
  value: string | number; 
  icon: string;
}) {
  return (
    <div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-4">
      <div className="flex items-center justify-between mb-2">
        <span className="text-2xl">{icon}</span>
      </div>
      <div className="text-sm text-gray-600 mb-1">{title}</div>
      <div className="text-2xl font-bold text-blue-900">{value}</div>
    </div>
  );
}

โœ… Chapter 6 Summary

What we built: Statistics dashboard and recipe merging

What we learned: - Semigroup - Associative combination operation - Monoid - Semigroup with identity element - M.concatAll - Reduce arrays using Monoid - Data aggregation - Powerful statistics with Monoids - Custom Semigroups - Define how types combine

Key insight: Semigroup and Monoid make data combination principled and composable!


Chapter 7: State Monad - Cooking Session Tracker

What we're building: Cooking session manager with step-by-step tracking

fp-ts concepts: State monad, stateful computations

Why State Monad?

When tracking a cooking session, we need to: - Track current step - Record time elapsed - Mark ingredients as used - Add notes

Traditional approach: manually thread state everywhere ๐Ÿ˜ซ

// โŒ Manual state threading
function nextStep(session: Session): Session {
  return { ...session, currentStep: session.currentStep + 1 };
}

function addTime(minutes: number, session: Session): Session {
  return { ...session, timeElapsed: session.timeElapsed + minutes };
}

// Composing is painful
let session = initialSession;
session = nextStep(session);
session = addTime(5, session);
session = useIngredient("flour", session);

State monad solution: composition without manual threading! ๐ŸŽ‰

Step 1: Understanding State

// lib/core/state-tutorial.ts
import * as St from 'fp-ts/State';
import { pipe } from 'fp-ts/function';

/**
 * State<S, A> is a stateful computation
 * Type: (s: S) => [A, S]
 * Takes state S, returns value A and new state S
 */

// Example: Counter
type Counter = number;

// Get current value
const get: St.State<Counter, number> = (counter) => [counter, counter];

// Set new value
const set = (n: number): St.State<Counter, void> => 
  () => [undefined, n];

// Modify state
const modify = (f: (n: number) => number): St.State<Counter, void> =>
  (counter) => [undefined, f(counter)];

// Increment counter
const increment: St.State<Counter, void> = modify(n => n + 1);

// Decrement counter
const decrement: St.State<Counter, void> = modify(n => n - 1);

// Execute state computation
const [value, newState] = get(5);
// value: 5, newState: 5

const [_, incremented] = increment(5);
// incremented: 6

// Compose state computations
const incrementTwice: St.State<Counter, void> = pipe(
  increment,
  St.chain(() => increment)
);

const [__, finalState] = incrementTwice(5);
// finalState: 7

// Return values from state computations
const getAfterIncrement: St.State<Counter, number> = pipe(
  increment,
  St.chain(() => get)
);

const [result, state] = getAfterIncrement(5);
// result: 6, state: 6

State Operations:

// gets - read state and transform it
const getDouble: St.State<number, number> = St.gets(n => n * 2);

getDouble(5);  // [10, 5] - value is doubled, state unchanged

// put - replace state
const reset: St.State<number, void> = St.put(0);

reset(5);  // [undefined, 0] - state reset to 0

// modify - transform state
const double: St.State<number, void> = St.modify((n: number) => n * 2);

double(5);  // [undefined, 10] - state doubled

// Composition
const process: St.State<number, number> = pipe(
  St.modify((n: number) => n + 1),  // Increment
  St.chain(() => St.modify((n: number) => n * 2)),  // Double
  St.chain(() => St.get())  // Get result
);

process(5);  // [12, 12] - (5 + 1) * 2 = 12

Step 2: Define Cooking Session State

// lib/domain/cooking-session.ts
import * as O from 'fp-ts/Option';
import { Recipe } from './recipe';

export interface CookingSession {
  readonly recipe: Recipe;
  readonly currentStep: number;
  readonly timeElapsed: number;  // minutes
  readonly ingredientsUsed: readonly string[];
  readonly notes: readonly string[];
  readonly completed: boolean;
  readonly startedAt: Date;
}

export const createSession = (recipe: Recipe): CookingSession => ({
  recipe,
  currentStep: 0,
  timeElapsed: 0,
  ingredientsUsed: [],
  notes: [],
  completed: false,
  startedAt: new Date()
});

/**
 * Get current step description
 */
export const getCurrentStepDescription = (
  session: CookingSession
): O.Option<string> =>
  pipe(
    session.recipe.steps,
    A.lookup(session.currentStep)
  );

/**
 * Check if session is complete
 */
export const isSessionComplete = (session: CookingSession): boolean =>
  session.currentStep >= session.recipe.steps.length;

Step 3: State-based Session Operations

// lib/recipes/session-operations.ts
import * as St from 'fp-ts/State';
import * as A from 'fp-ts/Array';
import { pipe } from 'fp-ts/function';
import { CookingSession } from '../domain/cooking-session';

/**
 * Move to next step
 */
export const nextStep: St.State<CookingSession, void> =
  St.modify(session => ({
    ...session,
    currentStep: session.currentStep + 1,
    completed: session.currentStep + 1 >= session.recipe.steps.length
  }));

/**
 * Go to previous step
 */
export const previousStep: St.State<CookingSession, void> =
  St.modify(session => ({
    ...session,
    currentStep: Math.max(0, session.currentStep - 1)
  }));

/**
 * Add time elapsed
 */
export const addTime = (minutes: number): St.State<CookingSession, void> =>
  St.modify(session => ({
    ...session,
    timeElapsed: session.timeElapsed + minutes
  }));

/**
 * Mark ingredient as used
 */
export const useIngredient = (name: string): St.State<CookingSession, void> =>
  St.modify(session => ({
    ...session,
    ingredientsUsed: pipe(
      session.ingredientsUsed,
      A.append(name)
    )
  }));

/**
 * Add a note
 */
export const addNote = (note: string): St.State<CookingSession, void> =>
  St.modify(session => ({
    ...session,
    notes: pipe(
      session.notes,
      A.append(note)
    )
  }));

/**
 * Get current step number
 */
export const getCurrentStep: St.State<CookingSession, number> =
  St.gets(session => session.currentStep);

/**
 * Get time elapsed
 */
export const getTimeElapsed: St.State<CookingSession, number> =
  St.gets(session => session.timeElapsed);

/**
 * Complete a cooking step (adds time and moves to next)
 */
export const completeStep = (
  minutesTaken: number
): St.State<CookingSession, void> =>
  pipe(
    addTime(minutesTaken),
    St.chain(() => nextStep)
  );

/**
 * Complex operation: use ingredients for current step
 */
export const useIngredientsForCurrentStep: St.State<CookingSession, void> =
  pipe(
    St.get<CookingSession>(),
    St.chain(session => {
      // Get current step description to find ingredients
      const currentStepDesc = pipe(
        session.recipe.steps,
        A.lookup(session.currentStep)
      );

      // Find ingredients mentioned in current step
      const ingredientsInStep = pipe(
        currentStepDesc,
        O.fold(
          () => [],
          stepDesc =>
            pipe(
              session.recipe.ingredients,
              A.filter(ing =>
                stepDesc.toLowerCase().includes(ing.name.toLowerCase())
              ),
              A.map(ing => ing.name)
            )
        )
      );

      // Mark each as used
      return pipe(
        ingredientsInStep,
        A.map(useIngredient),
        A.reduce(St.of<CookingSession, void>(undefined), (acc, action) =>
          pipe(acc, St.chain(() => action))
        )
      );
    })
  );

Understanding State Composition:

// Each operation returns State<S, A>
// We can chain them together

const cookingWorkflow: St.State<CookingSession, void> = pipe(
  useIngredient("flour"),
  St.chain(() => useIngredient("eggs")),
  St.chain(() => addTime(5)),
  St.chain(() => addNote("Mixed dry ingredients")),
  St.chain(() => nextStep)
);

// Execute workflow
const initialSession = createSession(recipe);
const [_, finalSession] = cookingWorkflow(initialSession);

// finalSession has all changes applied!

Step 4: Cooking Session Component

// app/cook/[id]/page.tsx
'use client';

import { useEffect, useState } from 'react';
import * as St from 'fp-ts/State';
import * as O from 'fp-ts/Option';
import * as A from 'fp-ts/Array';
import { pipe } from 'fp-ts/function';
import { Recipe } from '@/lib/domain/recipe';
import { CookingSession, createSession, getCurrentStepDescription } from '@/lib/domain/cooking-session';
import {
  nextStep,
  previousStep,
  addTime,
  useIngredient,
  addNote,
  completeStep
} from '@/lib/recipes/session-operations';

export default function CookSessionPage({ params }: { params: { id: string } }) {
  const [recipe, setRecipe] = useState<O.Option<Recipe>>(O.none);
  const [session, setSession] = useState<CookingSession | null>(null);
  const [noteInput, setNoteInput] = useState('');
  const [timeInput, setTimeInput] = useState('5');

  useEffect(() => {
    fetch('/api/recipes')
      .then(r => r.json())
      .then((recipes: Recipe[]) => {
        const found = pipe(
          recipes,
          A.findFirst(r => r.id === params.id)
        );

        pipe(
          found,
          O.fold(
            () => {},
            r => {
              setRecipe(found);
              setSession(createSession(r));
            }
          )
        );
      });
  }, [params.id]);

  if (!session || O.isNone(recipe)) {
    return <div className="p-6">Loading...</div>;
  }

  const updateSession = (operation: St.State<CookingSession, void>) => {
    const [_, newSession] = operation(session);
    setSession(newSession);
  };

  const currentStepDesc = getCurrentStepDescription(session);
  const progress = (session.currentStep / session.recipe.steps.length) * 100;

  return (
    <div className="max-w-4xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-2">Cooking: {session.recipe.title}</h1>
      <div className="text-gray-600 mb-6">
        Time elapsed: {session.timeElapsed} minutes
      </div>

      {/* Progress Bar */}
      <div className="mb-6">
        <div className="flex justify-between text-sm text-gray-600 mb-2">
          <span>Step {session.currentStep + 1} of {session.recipe.steps.length}</span>
          <span>{Math.round(progress)}% complete</span>
        </div>
        <div className="w-full bg-gray-200 rounded-full h-2">
          <div
            className="bg-blue-600 h-2 rounded-full transition-all"
            style={{ width: `${progress}%` }}
          />
        </div>
      </div>

      {/* Current Step */}
      {!session.completed ? (
        <div className="bg-white border-2 border-blue-500 rounded-lg p-6 mb-6">
          <h2 className="text-xl font-bold mb-4">Current Step</h2>
          {pipe(
            currentStepDesc,
            O.fold(
              () => <p className="text-gray-500">No more steps!</p>,
              desc => <p className="text-lg">{desc}</p>
            )
          )}

          <div className="flex gap-2 mt-6">
            <button
              onClick={() => updateSession(previousStep)}
              disabled={session.currentStep === 0}
              className="px-4 py-2 border rounded hover:bg-gray-50 disabled:opacity-50"
            >
              โ† Previous
            </button>
            <button
              onClick={() => updateSession(nextStep)}
              className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
            >
              Next Step โ†’
            </button>
          </div>
        </div>
      ) : (
        <div className="bg-green-50 border-2 border-green-500 rounded-lg p-6 mb-6 text-center">
          <h2 className="text-2xl font-bold text-green-800 mb-2">
            ๐ŸŽ‰ Cooking Complete!
          </h2>
          <p className="text-green-700">
            Total time: {session.timeElapsed} minutes
          </p>
        </div>
      )}

      {/* Quick Actions */}
      <div className="grid grid-cols-2 gap-4 mb-6">
        <div className="border rounded-lg p-4">
          <h3 className="font-bold mb-2">Add Time</h3>
          <div className="flex gap-2">
            <input
              type="number"
              value={timeInput}
              onChange={(e) => setTimeInput(e.target.value)}
              className="flex-1 border rounded px-3 py-2"
              min="1"
            />
            <button
              onClick={() => {
                updateSession(addTime(parseInt(timeInput) || 5));
                setTimeInput('5');
              }}
              className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
            >
              Add
            </button>
          </div>
        </div>

        <div className="border rounded-lg p-4">
          <h3 className="font-bold mb-2">Add Note</h3>
          <div className="flex gap-2">
            <input
              type="text"
              value={noteInput}
              onChange={(e) => setNoteInput(e.target.value)}
              placeholder="Note..."
              className="flex-1 border rounded px-3 py-2"
            />
            <button
              onClick={() => {
                if (noteInput.trim()) {
                  updateSession(addNote(noteInput));
                  setNoteInput('');
                }
              }}
              className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
            >
              Add
            </button>
          </div>
        </div>
      </div>

      {/* Ingredients Used */}
      <div className="border rounded-lg p-4 mb-6">
        <h3 className="font-bold mb-3">Ingredients Used</h3>
        <div className="flex flex-wrap gap-2 mb-3">
          {session.recipe.ingredients.map((ing, idx) => (
            <button
              key={idx}
              onClick={() => updateSession(useIngredient(ing.name))}
              disabled={session.ingredientsUsed.includes(ing.name)}
              className={`px-3 py-1 rounded ${
                session.ingredientsUsed.includes(ing.name)
                  ? 'bg-green-100 text-green-800'
                  : 'bg-gray-100 hover:bg-gray-200'
              }`}
            >
              {session.ingredientsUsed.includes(ing.name) && 'โœ“ '}
              {ing.name}
            </button>
          ))}
        </div>
      </div>

      {/* Notes */}
      {!A.isEmpty(session.notes) && (
        <div className="border rounded-lg p-4">
          <h3 className="font-bold mb-3">Notes</h3>
          <ul className="space-y-2">
            {session.notes.map((note, idx) => (
              <li key={idx} className="text-sm bg-yellow-50 p-2 rounded">
                ๐Ÿ“ {note}
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

โœ… Chapter 7 Summary

What we built: Interactive cooking session tracker

What we learned: - State monad - Stateful computations without manual threading - St.modify - Transform state - St.get/St.gets - Read state - St.chain - Compose stateful operations - State composition - Build complex workflows from simple operations

Key insight: State monad eliminates manual state threading, making stateful code clean!


Conclusion: Mastering fp-ts

What You've Accomplished

Congratulations! You've built a complete Recipe Manager application while mastering fp-ts fundamentals:

โœ… Core Concepts - Option - Safe handling of missing values - Either - Explicit error handling with details - TaskEither - Async operations with error handling - Reader - Dependency injection pattern - State - Stateful computations

โœ… Type Classes - Eq - Equality comparison - Ord - Ordering and sorting - Semigroup - Associative combination - Monoid - Combination with identity - Functor - Mapping over containers

โœ… Practical Skills - pipe & flow - Function composition - Array operations - Functional data transformation - Do notation - Combining effects - Error types - Type-safe error handling - Data aggregation - Statistical operations

Key Principles You've Learned

  1. Make invalid states unrepresentable - Use types to prevent bugs
  2. Explicit over implicit - Errors and effects are visible in types
  3. Composition over mutation - Build complex behavior from simple pieces
  4. Pure functions - Predictable, testable code
  5. Type-driven development - Let the compiler guide you

Next Steps

Deepen Your Knowledge: - Explore more type classes: Applicative, Alternative, Traversable - Learn advanced patterns: Free monads, Effect systems - Study category theory foundations - Contribute to fp-ts ecosystem

Apply in Real Projects: - Start small - add fp-ts to one module - Use Option/Either first - biggest immediate impact - Gradually introduce Reader for dependency injection - Use TaskEither for all async operations

Resources: - fp-ts documentation: https://gcanti.github.io/fp-ts/ - fp-ts Discord community - "Functional Programming in Scala" (concepts apply to fp-ts) - Category theory resources

The fp-ts Philosophy

fp-ts isn't just about avoiding null or handling errors - it's about: - Reasoning - Code you can understand by looking at types - Confidence - Compiler catches bugs before runtime - Composition - Building complex systems from simple parts - Maintainability - Code that's easy to change

You've learned to think functionally. Now go build amazing applications! ๐Ÿš€


Appendix: Quick Reference

Common Patterns

// Converting Option to Either
pipe(
  optionValue,
  E.fromOption(() => "error message")
)

// Converting Either to Option
pipe(
  eitherValue,
  O.fromEither
)

// Chaining async operations
pipe(
  TE.tryCatch(() => fetchData(), e => `Error: ${e}`),
  TE.chain(data => TE.tryCatch(() => saveData(data), e => `Error: ${e}`))
)

// Combining multiple Options
pipe(
  O.Do,
  O.apS('name', getName()),
  O.apS('age', getAge()),
  O.map(({ name, age }) => ({ name, age }))
)

// Filtering and sorting arrays
pipe(
  array,
  A.filter(predicate),
  A.sort(ord),
  A.map(transform)
)

// Using Reader for config
const formatter: R.Reader<Config, (val: number) => string> =
  (config) => (val) => format(val, config);

const fmt = formatter(config);
fmt(42);

Import Cheat Sheet

import * as O from 'fp-ts/Option';
import * as E from 'fp-ts/Either';
import * as TE from 'fp-ts/TaskEither';
import * as R from 'fp-ts/Reader';
import * as RTE from 'fp-ts/ReaderTaskEither';
import * as St from 'fp-ts/State';
import * as A from 'fp-ts/Array';
import * as Eq from 'fp-ts/Eq';
import * as Ord from 'fp-ts/Ord';
import * as S from 'fp-ts/Semigroup';
import * as M from 'fp-ts/Monoid';
import { pipe, flow } from 'fp-ts/function';

This tutorial is complete! You now have a solid foundation in fp-ts and functional programming in TypeScript. Happy coding! ๐ŸŽ‰