Skip to content

Latest commit

 

History

History
486 lines (375 loc) · 12.3 KB

File metadata and controls

486 lines (375 loc) · 12.3 KB
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
optional
composition
chaining
data-flow
functional-programming
error-handling
rule
description
Use Option combinators (map, flatMap, ap) to compose operations that may fail, creating readable and maintainable pipelines.
related
optional-pattern-handling-none-some
stream-pattern-map-filter-transformations
error-handling-pattern-propagation
author effect_website
lessonOrder 3

Guideline

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


Rationale

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:

  • None flows 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

Good Example

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);

Advanced: Monadic Comprehension

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 };
});

Advanced: Collecting Multiple Options

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

When to Use This Pattern

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

⚠️ Trade-offs:

  • Mental model shift required
  • More operators to learn
  • Potential performance overhead (negligible)
  • Debugging chains harder

Chain Patterns

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)

See Also