| title | Optional Pattern 2: Optional Chaining and Composition | ||||||
|---|---|---|---|---|---|---|---|
| id | optional-pattern-optional-chains | ||||||
| skillLevel | advanced | ||||||
| applicationPatternId | value-handling | ||||||
| summary | Chain optional values across multiple steps with composable operators, enabling elegant data flow through systems with missing values. | ||||||
| tags |
|
||||||
| rule |
|
||||||
| related |
|
||||||
| author | effect_website | ||||||
| lessonOrder | 3 |
Option chaining enables elegant data flows:
- map: Transform value if present
- flatMap: Chain operations that return Option
- ap: Apply functions wrapped in Option
- traverse: Map over collections with Option
- composition: Combine multiple chains
- recovery: Provide fallbacks
Pattern: Use Option.map(), flatMap(), ap(), pipe operators
Nested option handling becomes complex:
Problem 1: Pyramid of doom
if (user !== null) {
if (user.profile !== null) {
if (user.profile.preferences !== null) {
if (user.profile.preferences.theme !== null) {
// Finally do thing
}
}
}
}Problem 2: Repeated null checks
- Every step needs its own check
- Code duplicates
- Hard to refactor
- Bugs easy to introduce
Problem 3: Logic scattered
- Transformation logic mixed with null checks
- Hard to understand intent
- Error-prone
Solutions:
Option chaining:
Noneflows through automatically- Transform only if
Some - No intermediate checks needed
Composition:
- Combine functions cleanly
- Separate concerns
- Reusable pieces
Fallbacks:
orElse()for recovery- Chain multiple alternatives
- Graceful degradation
This example demonstrates optional chaining patterns.
import { Effect, Option, pipe } from "effect";
interface User {
id: string;
name: string;
email: string;
}
interface Profile {
bio: string;
website?: string;
avatar?: string;
}
interface Settings {
theme: "light" | "dark";
notifications: boolean;
language: string;
}
const program = Effect.gen(function* () {
console.log(`\n[OPTIONAL CHAINING] Composing Option operations\n`);
// Example 1: Simple chain with map
console.log(`[1] Chaining transformations with map():\n`);
const userId: Option.Option<string> = Option.some("user-42");
const userDisplayId = Option.map(userId, (id) => `User#${id}`);
const idMessage = Option.match(userDisplayId, {
onSome: (display) => display,
onNone: () => "No user ID",
});
yield* Effect.log(`[CHAIN 1] ${idMessage}`);
// Chained maps
const email: Option.Option<string> = Option.some("alice@example.com");
const emailParts = pipe(
email,
Option.map((e) => e.toLowerCase()),
Option.map((e) => e.split("@")),
Option.map((parts) => parts[0]) // username
);
const username = Option.getOrElse(emailParts, () => "unknown");
yield* Effect.log(`[USERNAME] ${username}\n`);
// Example 2: FlatMap for chaining operations that return Option
console.log(`[2] Chaining operations with flatMap():\n`);
const findUser = (id: string): Option.Option<User> =>
id === "user-42"
? Option.some({
id,
name: "Alice",
email: "alice@example.com",
})
: Option.none();
const getProfile = (userId: string): Option.Option<Profile> =>
userId === "user-42"
? Option.some({
bio: "Software engineer",
website: "alice.dev",
avatar: "https://example.com/avatar.jpg",
})
: Option.none();
const userProfile = pipe(
Option.some("user-42"),
Option.flatMap((id) => findUser(id)),
Option.flatMap((user) => getProfile(user.id))
);
const profileInfo = Option.match(userProfile, {
onSome: (profile) => `Bio: ${profile.bio}, Website: ${profile.website}`,
onNone: () => "Profile not found",
});
yield* Effect.log(`[PROFILE] ${profileInfo}\n`);
// Example 3: Complex pipeline
console.log(`[3] Complex pipeline (user → profile → settings → theme):\n`);
const getSettings = (userId: string): Option.Option<Settings> =>
userId === "user-42"
? Option.some({
theme: "dark",
notifications: true,
language: "en",
})
: Option.none();
const userTheme = pipe(
Option.some("user-42"),
Option.flatMap((id) => findUser(id)),
Option.flatMap((user) => getSettings(user.id)),
Option.map((settings) => settings.theme)
);
const theme = Option.getOrElse(userTheme, () => "light");
yield* Effect.log(`[THEME] ${theme}`);
// Even if any step is None, result is None
const invalidUserTheme = pipe(
Option.some("invalid-user"),
Option.flatMap((id) => findUser(id)),
Option.flatMap((user) => getSettings(user.id)),
Option.map((settings) => settings.theme)
);
const invalidTheme = Option.getOrElse(invalidUserTheme, () => "light");
yield* Effect.log(`[DEFAULT THEME] ${invalidTheme}\n`);
// Example 4: Apply (ap) for combining independent Options
console.log(`[4] Combining values with ap():\n`);
const firstName: Option.Option<string> = Option.some("John");
const lastName: Option.Option<string> = Option.some("Doe");
// Create a function wrapped in Option
const combineNames = (first: string) => (last: string) =>
`${first} ${last}`;
const fullName = pipe(
Option.some(combineNames),
Option.ap(firstName),
Option.ap(lastName)
);
const name = Option.getOrElse(fullName, () => "Unknown");
yield* Effect.log(`[COMBINED] ${name}`);
// If any is None
const noLastName: Option.Option<string> = Option.none();
const incompleteName = pipe(
Option.some(combineNames),
Option.ap(firstName),
Option.ap(noLastName)
);
const incompleteFull = Option.getOrElse(incompleteName, () => "Incomplete");
yield* Effect.log(`[INCOMPLETE] ${incompleteFull}\n`);
// Example 5: Traverse for mapping over collections
console.log(`[5] Working with collections (traverse):\n`);
const userIds: string[] = ["user-42", "user-99", "user-1"];
// Try to load all users
const allUsers = Option.all(
userIds.map((id) => findUser(id))
);
const usersMessage = Option.match(allUsers, {
onSome: (users) => `Loaded ${users.length} users`,
onNone: () => "Some users not found",
});
yield* Effect.log(`[TRAVERSE] ${usersMessage}\n`);
// Example 6: Or/recovery with multiple options
console.log(`[6] Fallback chains with orElse():\n`);
const getPrimaryEmail = (): Option.Option<string> => Option.none();
const getSecondaryEmail = (): Option.Option<string> =>
Option.some("backup@example.com");
const getTertiaryEmail = (): Option.Option<string> =>
Option.some("tertiary@example.com");
const email1 = pipe(
getPrimaryEmail(),
Option.orElse(() => getSecondaryEmail()),
Option.orElse(() => getTertiaryEmail())
);
const contactEmail = Option.getOrElse(email1, () => "no-email@example.com");
yield* Effect.log(`[FALLBACK] Using email: ${contactEmail}\n`);
// Example 7: Filtering options
console.log(`[7] Filtering with predicates:\n`);
const age: Option.Option<number> = Option.some(25);
const canVote = pipe(
age,
Option.filter((a) => a >= 18)
);
const voteStatus = Option.match(canVote, {
onSome: () => "Can vote",
onNone: () => "Too young to vote",
});
yield* Effect.log(`[FILTER] ${voteStatus}`);
// Multiple filters in chain
const score: Option.Option<number> = Option.some(85);
const isAGrade = pipe(
score,
Option.filter((s) => s >= 80),
Option.filter((s) => s < 90)
);
const grade = Option.match(isAGrade, {
onSome: () => "Grade A",
onNone: () => "Not in A range",
});
yield* Effect.log(`[GRADES] ${grade}\n`);
// Example 8: Practical: Database query chain
console.log(`[8] Real-world: Database record chain:\n`);
const getRecord = (id: string): Option.Option<{ data: string; nested: { value: number } }> =>
id === "rec-1"
? Option.some({
data: "content",
nested: { value: 42 },
})
: Option.none();
const recordValue = pipe(
Option.some("rec-1"),
Option.flatMap((id) => getRecord(id)),
Option.map((rec) => rec.nested),
Option.map((nested) => nested.value),
Option.map((value) => value * 2)
);
const finalValue = Option.getOrElse(recordValue, () => 0);
yield* Effect.log(`[VALUE] ${finalValue}`);
// Missing record
const missingValue = pipe(
Option.some("rec-999"),
Option.flatMap((id) => getRecord(id)),
Option.map((rec) => rec.nested),
Option.map((nested) => nested.value),
Option.map((value) => value * 2)
);
const defaultValue = Option.getOrElse(missingValue, () => 0);
yield* Effect.log(`[DEFAULT] ${defaultValue}\n`);
// Example 9: Conditional chaining
console.log(`[9] Conditional paths:\n`);
const loadUserWithFallback = (id: string) =>
pipe(
findUser(id),
Option.flatMap((user) =>
// Only get premium features if user exists
user.name.includes("Alice")
? Option.some({ ...user, isPremium: true })
: Option.none()
),
Option.orElse(() =>
// Fallback: return basic user
findUser(id)
)
);
const result1 = loadUserWithFallback("user-42");
const result2 = loadUserWithFallback("user-99");
yield* Effect.log(
`[CONDITIONAL 1] ${Option.match(result1, { onSome: (u) => `${u.name}`, onNone: () => "Not found" })}`
);
yield* Effect.log(
`[CONDITIONAL 2] ${Option.match(result2, { onSome: (u) => `${u.name}`, onNone: () => "Not found" })}`
);
});
Effect.runPromise(program);Use do-notation for complex chains:
const complexChain = pipe(
Option.Do,
Option.bind("user", () => findUser("user-42")),
Option.bind("profile", ({ user }) => getProfile(user.id)),
Option.bind("settings", ({ user }) => getSettings(user.id)),
Option.map(({ user, profile, settings }) => ({
name: user.name,
bio: profile.bio,
theme: settings.theme,
}))
);
// Or with effect.gen syntax
const withGen = Effect.gen(function* () {
const user = yield* Option.fromNullable(findUser("user-42"));
const profile = yield* Option.fromNullable(getProfile(user.id));
const settings = yield* Option.fromNullable(getSettings(user.id));
return { user, profile, settings };
});Combine multiple independent Option values:
const combineAll = <T,>(options: Option.Option<T>[]): Option.Option<T[]> =>
options.reduce(
(acc, opt) =>
pipe(
acc,
Option.flatMap((values) =>
Option.map(opt, (value) => [...values, value])
)
),
Option.some([])
);
// Usage: Load all or nothing
const results = combineAll([
findUser("user-1"),
findUser("user-2"),
findUser("user-3"),
]);
// Gets Some([user1, user2, user3]) or None if any missing✅ Use chaining when:
- Multiple dependent operations
- Each returns Option
- Clean data flow needed
- Avoiding null checks
- Readable pipelines
✅ Use composition when:
- Reusable transformations
- Complex business logic
- Multiple alternative paths
- Fallback chains
- Mental model shift required
- More operators to learn
- Potential performance overhead (negligible)
- Debugging chains harder
| Pattern | Use Case | Example |
|---|---|---|
| map | Transform value | Option.map(opt, x => x * 2) |
| flatMap | Chain operations | Option.flatMap(opt, x => getOther(x)) |
| ap | Apply function | Option.ap(func, arg) |
| traverse | Map over array | Option.all([opt1, opt2]) |
| orElse | Fallback | Option.orElse(opt, alt) |
- Optional Pattern 1: Handling None/Some - Basics
- Stream Pattern 1: Map & Filter - Stream chaining
- Stream Pattern 2: Merge & Combine - Stream composition
- Error Handling Pattern 2: Propagation - Error chains