| 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 |
|
||||||
| rule |
|
||||||
| related |
|
||||||
| author | effect_website | ||||||
| lessonOrder | 1 |
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 })orEffect.gento execute different effects based on a condition.
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:
- Separation of Concerns: It cleanly separates the logic of producing a value from the logic of validating or making decisions about that value.
- 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 inlineifstatements 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.
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);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.- 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.filterOrFailis superior:filterOrFailguarantees that if the condition fails, the computation fails, preserving the integrity of the success channel.