Skip to content

Latest commit

 

History

History
145 lines (115 loc) · 5.29 KB

File metadata and controls

145 lines (115 loc) · 5.29 KB
title Conditionally Branching Workflows
id conditionally-branching-workflows
skillLevel intermediate
applicationPatternId error-management
summary Use predicate-based operators like Effect.filter and Effect.if to make decisions and control the flow of your application based on runtime values.
tags
predicate
filter
if
validation
control-flow
conditional
rule
description
Use predicate-based operators like Effect.filter and Effect.if to declaratively control workflow branching.
related
control-flow-with-combinators
model-validated-domain-types-with-brand
author effect_website
lessonOrder 1

Pattern: conditionally-branching-workflows.mdx

Guideline

To make decisions based on a successful value within an Effect pipeline, use predicate-based operators:

  • To Validate and Fail: Use Effect.filterOrFail(predicate, onFailure) to stop the workflow if a condition is not met.
  • To Choose a Path: Use Effect.if(condition, { onTrue, onFalse }) or Effect.gen to execute different effects based on a condition.

Rationale

This pattern allows you to embed decision-making logic directly into your composition pipelines, making your code more declarative and readable. It solves two key problems:

  1. Separation of Concerns: It cleanly separates the logic of producing a value from the logic of validating or making decisions about that value.
  2. Reusable Business Logic: A predicate function (e.g., const isAdmin = (user: User) => ...) becomes a named, reusable, and testable piece of business logic, far superior to scattering inline if statements throughout your code.

Using these operators turns conditional logic into a composable part of your Effect, rather than an imperative statement that breaks the flow.


Good Example: Validating a User

Here, we use Effect.filterOrFail with named predicates to validate a user before proceeding. The intent is crystal clear, and the business rules (isActive, isAdmin) are reusable.

import { Effect } from "effect";

interface User {
  id: number;
  status: "active" | "inactive";
  roles: string[];
}

type UserError = "DbError" | "UserIsInactive" | "UserIsNotAdmin";

const findUser = (id: number): Effect.Effect<User, "DbError"> =>
  Effect.succeed({ id, status: "active", roles: ["admin"] });

// Reusable, testable predicates that document business rules.
const isActive = (user: User): boolean => user.status === "active";

const isAdmin = (user: User): boolean => user.roles.includes("admin");

const program = (id: number): Effect.Effect<string, UserError> =>
  findUser(id).pipe(
    // Validate user is active using Effect.filterOrFail
    Effect.filterOrFail(isActive, () => "UserIsInactive" as const),
    // Validate user is admin using Effect.filterOrFail
    Effect.filterOrFail(isAdmin, () => "UserIsNotAdmin" as const),
    // Success case
    Effect.map((user) => `Welcome, admin user #${user.id}!`)
  );

// We can then handle the specific failures in a type-safe way.
const handled = program(123).pipe(
  Effect.match({
    onFailure: (error) => {
      switch (error) {
        case "UserIsNotAdmin":
          return "Access denied: requires admin role.";
        case "UserIsInactive":
          return "Access denied: user is not active.";
        case "DbError":
          return "Error: could not find user.";
        default:
          return `Unknown error: ${error}`;
      }
    },
    onSuccess: (result) => result,
  })
);

// Run the program
const programWithLogging = Effect.gen(function* () {
  const result = yield* handled;
  yield* Effect.log(result);
  return result;
});

Effect.runPromise(programWithLogging);

Anti-Pattern

Using Effect.flatMap with a manual if statement and forgetting to handle the else case. This is a common mistake that leads to an inferred type of Effect<void, ...>, which can cause confusing type errors downstream because the success value is lost.

import { Effect } from "effect";
import { findUser, isAdmin } from "./somewhere"; // From previous example

// ❌ WRONG: The `else` case is missing.
const program = (id: number) =>
  findUser(id).pipe(
    Effect.flatMap((user) => {
      if (isAdmin(user)) {
        // This returns Effect<User>, but what happens if the user is not an admin?
        return Effect.succeed(user);
      }
      // Because there's no `else` branch, TypeScript infers that this
      // block can also implicitly return `void`.
      // The resulting type is Effect<User | void, "DbError">, which is problematic.
    }),
    // This `map` will now have a type error because `u` could be `void`.
    Effect.map((u) => `Welcome, ${u.name}!`)
  );

// `Effect.filterOrFail` avoids this problem entirely by forcing a failure,
// which keeps the success channel clean and correctly typed.

Why This is Better

  • It's a Real Bug: This isn't just a style issue; it's a legitimate logical error that leads to incorrect types and broken code.
  • It's a Common Mistake: Developers new to functional pipelines often forget that every path must return a value.
  • It Reinforces the "Why": It perfectly demonstrates why Effect.filterOrFail is superior: filterOrFail guarantees that if the condition fails, the computation fails, preserving the integrity of the success channel.