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
- Make invalid states unrepresentable - Use types to prevent bugs
- Explicit over implicit - Errors and effects are visible in types
- Composition over mutation - Build complex behavior from simple pieces
- Pure functions - Predictable, testable code
- 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! ๐