Chapter 7: Effect Fundamentals
Effect is the architectural backbone of our application. This chapter teaches you to think in Effects — how to create them, compose them, run them, and understand their type signatures.
What Is an Effect?
An Effect is a description of a computation that may:
- Succeed with a value of type
A - Fail with an error of type
E - Require services of type
Rto run
Effect<Success, Error, Requirements>
// A E R
This is the most important type in the entire framework. Every operation in your application — fetching data, validating input, saving to the database, checking permissions — is described as an Effect.
The key insight: An Effect does not execute when created. It is a blueprint. You build up a complete program by composing Effects, and only execute it at the very edge of your application.
import { Effect, Console } from "effect";
// This does NOT print anything — it creates a description
const program = Console.log("Hello, World!");
// This actually executes the program
Effect.runSync(program);
// Output: Hello, World!
Creating Effects
Succeeding
import { Effect } from "effect";
// An effect that succeeds with the number 42
const fortyTwo = Effect.succeed(42);
// Type: Effect<number, never, never>
// ↑ ↑ ↑
// Success No Error No Requirements
// An effect that succeeds with a string
const greeting = Effect.succeed("Hello");
// Type: Effect<string, never, never>
Failing
import { Effect } from "effect";
// An effect that fails with a string error
const failed = Effect.fail("Something went wrong");
// Type: Effect<never, string, never>
// ↑ ↑ ↑
// Never succeeds Error type No Requirements
// Prefer typed errors (covered in Chapter 9)
import { Data } from "effect";
class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{
readonly userId: string;
}> {}
const notFound = Effect.fail(new UserNotFoundError({ userId: "123" }));
// Type: Effect<never, UserNotFoundError, never>
From Synchronous Code
// Effect.sync — for synchronous code that does not throw
const now = Effect.sync(() => new Date());
// Type: Effect<Date, never, never>
// Effect.try — for synchronous code that might throw
const parseJson = (raw: string) =>
Effect.try({
try: () => JSON.parse(raw) as unknown,
catch: (error) => new JsonParseError({ raw, cause: error }),
});
// Type: Effect<unknown, JsonParseError, never>
From Promises
// Effect.tryPromise — for async code that might reject
const fetchUser = (id: string) =>
Effect.tryPromise({
try: () =>
fetch(`/api/users/${id}`).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<User>;
}),
catch: (error) => new FetchError({ cause: error }),
});
// Type: Effect<User, FetchError, never>
From Callbacks
// Effect.async — for callback-based APIs
const readFile = (path: string) =>
Effect.async<string, NodeError>((resume) => {
fs.readFile(path, "utf-8", (err, data) => {
if (err) {
resume(Effect.fail(new NodeError({ cause: err })));
} else {
resume(Effect.succeed(data));
}
});
});
The Generator Pattern: Effect.gen
The most common way to write Effect code uses generators, which give you an async/await-like syntax:
import { Effect } from "effect";
const program = Effect.gen(function* () {
// yield* "unwraps" an Effect — similar to await for Promises
const user = yield* fetchUser("123");
const projects = yield* fetchProjects(user.organizationId);
const stats = yield* calculateStats(projects);
return {
user,
projects,
stats,
};
});
yield* is to Effect what await is to Promises. But with a critical difference: the Effect type system tracks errors and requirements through the entire chain.
// If fetchUser can fail with UserNotFoundError
// and fetchProjects can fail with DatabaseError
// then the composed program can fail with EITHER:
// Type: Effect<
// { user: User; projects: Project[]; stats: Stats },
// UserNotFoundError | DatabaseError | StatsError,
// UserRepository | ProjectRepository | StatsService
// >
The type signature automatically accumulates all possible errors and all required services from every yield* in the generator. This is compile-time error tracking — no surprises at runtime.
Composing Effects with pipe
For functional composition, use pipe:
import { Effect, pipe } from "effect";
// Transform the success value
const doubled = pipe(
Effect.succeed(21),
Effect.map((n) => n * 2) // Always use explicit lambdas, not point-free
);
// Type: Effect<number, never, never>
// Value: 42
// Chain effects (flatMap)
const userProjects = pipe(
fetchUser("123"),
Effect.flatMap((user) => fetchProjects(user.organizationId))
);
// Type: Effect<Project[], UserNotFoundError | DatabaseError, ...>
// Add error handling
const safeUserProjects = pipe(
userProjects,
Effect.catchTag("UserNotFoundError", () => Effect.succeed([]))
);
// Type: Effect<Project[], DatabaseError, ...>
// UserNotFoundError is handled — only DatabaseError remains
⚠️ Always Use Explicit Lambdas
// ✅ Correct: explicit lambda
Effect.map((x) => transformFn(x));
// ❌ Wrong: tacit/point-free style
Effect.map(transformFn);
Point-free style causes issues with TypeScript type inference, especially with overloaded functions and generics. It also produces worse stack traces. Always wrap function calls in an explicit arrow function.
Running Effects
Effects are blueprints — they do nothing until you run them. There are several ways to execute an Effect:
Effect.runSync — Synchronous Execution
// Only works if the effect is synchronous and cannot fail
const result = Effect.runSync(Effect.succeed(42));
// result: 42
// Throws if the effect fails
Effect.runSync(Effect.fail("boom")); // throws
Effect.runPromise — Async Execution
// Returns a Promise — useful for integrating with non-Effect code
const result = await Effect.runPromise(fetchUser("123"));
// result: User (or throws if the effect fails)
Effect.runPromiseExit — Async with Exit Information
import { Exit } from "effect";
const exit = await Effect.runPromiseExit(fetchUser("123"));
if (Exit.isSuccess(exit)) {
console.log("User:", exit.value);
} else {
console.log("Error:", exit.cause);
}
Platform-Specific Entry Points
For the main entry point of your application, use the platform-specific runner:
// Node.js backend
import { NodeRuntime } from "@effect/platform-node";
NodeRuntime.runMain(program);
// Browser frontend
import { BrowserRuntime } from "@effect/platform-browser";
BrowserRuntime.runMain(program);
These provide:
- Graceful shutdown on SIGINT/SIGTERM
- Proper fiber interruption
- Unhandled error reporting
In React: Effect.runPromise with TanStack Query
In our React application, the primary integration point is TanStack Query:
function useUser(userId: string) {
return useQuery({
queryKey: ["users", userId],
queryFn: () =>
AppRuntime.runPromise(
UserService.findById(userId)
),
});
}
The AppRuntime is a ManagedRuntime that has all services pre-provided (→ See Chapter 8).
Understanding the Type Signature
Let's break down a real Effect type:
const createProject: (input: CreateProjectInput) => Effect.Effect<
Project, // Success: returns a Project
ProjectNameTakenError // Error: name might be taken
| InsufficientPermissionsError // Error: user might lack permission
| DatabaseError, // Error: database might fail
ProjectRepository // Requires: ProjectRepository service
| AuthorizationService // Requires: AuthorizationService
| AuditService // Requires: AuditService
>;
This single type tells you:
- What it returns on success
- Every way it can fail — and each error is a specific, typed class
- What services it needs to run — which must be provided before execution
Compare with a traditional function signature:
// Traditional — you know NOTHING about errors or dependencies
async function createProject(input: CreateProjectInput): Promise<Project>;
// Can it throw? What errors? What does it depend on? You have to read the implementation.
Practical Patterns
Pattern: Wrapping External APIs
// Wrap a third-party API to return Effects instead of Promises
const fetchFromApi = <T>(url: string, schema: Schema.Schema<T>) =>
Effect.gen(function* () {
const response = yield* Effect.tryPromise({
try: () => fetch(url),
catch: (error) => new NetworkError({ url, cause: error }),
});
if (!response.ok) {
yield* Effect.fail(
new HttpError({ status: response.status, url })
);
}
const json = yield* Effect.tryPromise({
try: () => response.json(),
catch: (error) => new ParseError({ cause: error }),
});
// Validate response against schema
return yield* Schema.decodeUnknown(schema)(json);
});
Pattern: Conditional Logic
const processTask = (task: Task) =>
Effect.gen(function* () {
if (task.status === "completed") {
return task; // Already done
}
if (task.priority === "urgent") {
yield* NotificationService.notifyTeam(task);
}
const updated = yield* TaskRepository.updateStatus(task.id, "in-progress");
yield* AuditService.record({
action: "task:started",
resourceId: task.id,
});
return updated;
});
Pattern: Parallel Execution
import { Effect } from "effect";
// Run multiple effects in parallel
const [user, projects, notifications] = yield* Effect.all(
[fetchUser(userId), fetchProjects(orgId), fetchNotifications(userId)],
{ concurrency: "unbounded" }
);
// Race — return the first to complete
const fastest = yield* Effect.race(
fetchFromPrimary(id),
fetchFromFallback(id)
);
// forEach with controlled concurrency
const results = yield* Effect.forEach(
userIds,
(id) => fetchUser(id),
{ concurrency: 5 } // Max 5 concurrent requests
);
Pattern: Timeouts
import { Duration, Effect } from "effect";
const withTimeout = pipe(
longRunningOperation,
Effect.timeout(Duration.seconds(10)),
// Returns Option<A> — None if timed out
);
// Or fail with a specific error on timeout
const withTimeoutError = pipe(
longRunningOperation,
Effect.timeoutFail({
duration: Duration.seconds(10),
onTimeout: () => new TimeoutError({ operation: "data-fetch" }),
}),
);
Effect vs. Promise: A Mental Model
| Concept | Promise | Effect |
|---|---|---|
| Create | new Promise(...) | Effect.succeed(...), Effect.gen(...) |
| Chain | .then(fn) | Effect.flatMap(fn) |
| Transform | .then(fn) | Effect.map(fn) |
| Handle error | .catch(fn) | Effect.catchAll(fn) |
| Await | await promise | yield* effect |
| Execute | Immediate | Effect.runPromise(effect) |
| Error type | unknown (untyped) | E (fully typed) |
| Dependencies | Global/closure | R (tracked in type) |
| Cancel | AbortController | Fiber interruption (built-in) |
| Retry | Manual | Effect.retry(schedule) |
The biggest mental shift: Promises execute immediately. Effects are lazy. An Effect does nothing until you explicitly run it. This makes Effects composable — you can build up complex programs from simple parts without triggering side effects along the way.
Common Mistakes
❌ Forgetting yield*
Effect.gen(function* () {
// ❌ This does NOT execute the effect — it just creates it
fetchUser("123");
// ✅ This executes it and unwraps the result
const user = yield* fetchUser("123");
});
❌ Using async/await Inside Effect.gen
// ❌ Don't mix async/await with Effect.gen
Effect.gen(async function* () {
const data = await fetch("/api/data"); // DON'T
});
// ✅ Wrap the promise in an Effect
Effect.gen(function* () {
const data = yield* Effect.tryPromise({
try: () => fetch("/api/data"),
catch: (e) => new FetchError({ cause: e }),
});
});
❌ Throwing Errors Inside Effects
// ❌ Throwing bypasses the Effect error channel
Effect.gen(function* () {
if (!isValid(input)) {
throw new Error("Invalid"); // This becomes a "defect", not a typed error
}
});
// ✅ Use Effect.fail for expected errors
Effect.gen(function* () {
if (!isValid(input)) {
yield* Effect.fail(new ValidationError({ input }));
}
});
Summary
- ✅
Effect<A, E, R>describes a computation with typed success, error, and requirement channels - ✅ Effects are lazy — they do nothing until run
- ✅ Use
Effect.genwithyield*for readable sequential code - ✅ Use
pipewithEffect.mapandEffect.flatMapfor functional composition - ✅ Use
Effect.tryPromiseto bridge from Promise-based APIs - ✅ Use explicit lambdas (never point-free style)
- ✅ The type system automatically tracks all possible errors and required services
- ✅ Run effects at the application boundary with
Effect.runPromiseorManagedRuntime
Effect transforms TypeScript from a language where errors are invisible and dependencies are implicit into one where the type signature tells you everything about what a function does, how it can fail, and what it needs to run.
🎮 Try It: Interactive Effect Playground
Edit the code below and see the results live in your browser: