05 - Effect Basics: Creating and Running Effects
An Effect<A, E, R> is an immutable, lazy description of a computation. Creating one does nothing on its own, execution is deferred until a runner is called.
This step covers the constructors that turn values, thunks, and throwing code into Effects, and the runners that execute them.
What you’ll work through
index.test.ts contains five beats: Effect.succeed / Effect.fail, Effect.sync, Effect.try, the four runners, and a small composition that mixes try and flatMap. Each beat has one or two stubs to fill.
By the end you should be able to pick the right constructor for a piece of side-effecting JS — value, thunk, or throwing code — and the right runner for the call site.
Effects Are Descriptions
The key property of an Effect is that it is a value, not a running computation. You build up a description, hand it to a runner, and only then does anything happen.
describe execute your code ──────────────────► Effect ───────────────► result │ Nothing runs here. The Effect is a value: composable, retryable, testable, traceable.This separation enables the rest of Effect. Retries, timeouts, dependency injection, and tracing all work because the description exists independently from its execution.
Express success and failure
Effect.succeed lifts a known value into the success channel. Effect.fail lifts a known error into the typed error channel.
Both are eager, the value is captured immediately, no computation runs.
import { Effect } from "effect"
// ┌─── Effect<number, never, never>// ▼const success = Effect.succeed(42)
class NotFound { readonly _tag = "NotFound"}
// ┌─── Effect<never, NotFound, never>// ▼const missing = Effect.fail(new NotFound())When to Use
Effect.succeed when returning a constant from a function that otherwise produces an Effect. Effect.fail to signal a known, recoverable failure, never throw, so the failure participates in the type system and the recovery operators in step 09.
Effect.fail represents an expected error. For unexpected defects (programmer bugs, violated invariants) see Effect.die.
Your turn replace the two stubs with `Effect.succeed(...)` and `Effect.fail(...)` so the first program lands in `Exit.Success` and the second in `Exit.Failure`.
Defer a synchronous side effect
Effect.sync wraps a synchronous, side-effecting thunk. The thunk runs every time the effect is executed, Effect.sync is the lazy, side-effectful counterpart to Effect.succeed.
import { Effect } from "effect"
const log = (message: string) => Effect.sync(() => { console.log(message) })
// ┌─── Effect<void, never, never>// ▼const program = log("Hello, World!")Details
The thunk must not throw. If it does, the exception becomes a defect, an unexpected failure outside the typed E channel.
Defects can be observed with Effect.catchDefect or surfaced through Effect.sandbox, but they signal a bug in code you believed could not fail. When the thunk can throw, reach for Effect.try instead.
When to Use
Wrapping synchronous code that you are certain cannot throw, counters, in-memory updates, logging, deterministic transformations.
> failure. `catchTag` will not see it. If the thunk can throw, reach for > `Effect.try` instead.Your turn wrap the increment-and-return so the test sees the counter advance on each run — every execution must re-fire the thunk.
Wrap code that may throw
Effect.try accepts a try thunk and a catch mapper. If the thunk throws, the unknown exception is passed to catch, which produces the typed error placed in the E channel.
import { Effect } from "effect"
class ParseError { readonly _tag = "ParseError" constructor(readonly cause: unknown) {}}
// ┌─── Effect<unknown, ParseError, never>// ▼const parseJson = (input: string) => Effect.try({ try: () => JSON.parse(input) as unknown, catch: (cause) => new ParseError(cause), })Details
JSON.parse returns any, which would silently bypass TypeScript. The as unknown cast forces the next step in the pipeline to validate the value.
The catch callback receives unknown, you decide what typed shape the error takes before it enters the E channel.
When to Use
Wrapping synchronous third-party code that may throw, or any API whose failure modes you want surfaced as typed values rather than exceptions.
Your turn return an `Effect.try` that parses `"{ invalid }"` and maps the exception to a `ParseError`.
Pick a runner
A runner is the boundary between the description and the world. Pick by the shape of the effect (sync or async) and how you want failure handled (thrown or returned as Exit).
| Runner | Returns | Sync only | On failure |
|---|---|---|---|
Effect.runSync |
A |
yes | throws |
Effect.runPromise |
Promise<A> |
no | rejects |
Effect.runSyncExit |
Exit<A, E> |
yes | returns Failure |
Effect.runPromiseExit |
Promise<Exit<A, E>> |
no | resolves to Failure |
import { Effect, Exit } from "effect"
const program = Effect.succeed(42)
Effect.runSync(program) // 42, throws on failureawait Effect.runPromise(program) // 42, rejects on failure
const exit = Effect.runSyncExit(program) // never throwsif (Exit.isSuccess(exit)) exit.value // 42When to Use
runSync / runPromise at the top of the program (main, a request handler) where uncaught failure should propagate. The *Exit variants when you need to observe success vs. failure as a value, e.g. logging both branches in a test.
Calling runSync on an effect that contains an asynchronous step throws: there is no event loop available to wait on. Use runPromise for anything async.
Your turn the third test uses the async, never-throwing runner.
runSync throws on async effects
Effect.runSync walks the description on the current call stack — there is no event loop to wait on. If any step in the effect is asynchronous (a Promise, Effect.sleep, an async callback), runSync throws at runtime.
The compiler will not catch this — both effects have the same Effect<A, E> type.
import { Effect } from "effect"
const asyncEffect = Effect.promise(() => Promise.resolve(42))
Effect.runSync(asyncEffect)// ✗ throws: AsyncFiberException — use runPromise instead
await Effect.runPromise(asyncEffect)// ✓ 42Rule of thumb. If anything in the effect can await, the runner must be runPromise (or runPromiseExit). Reach for runSync only when the effect is provably synchronous end-to-end — a tight pure transformation, a test assertion, or a Ref.get.
Your turn read the assertion that confirms `runSync` throws on the async effect, then read the partner test that proves `runPromise` resolves the same effect.
Compose: parse, then validate
Effect.flatMap chains a second effect onto the success channel of the first. The error channel widens automatically to the union of all possible failures, so the type of the composed effect always reflects every error it can produce.
import { Effect } from "effect"
type User = { readonly name: string }
class ParseError { readonly _tag = "ParseError" constructor(readonly cause: unknown) {}}class ValidationError { readonly _tag = "ValidationError"}
const isUser = (value: unknown): value is User => typeof value === "object" && value !== null && "name" in value && typeof (value as { name: unknown }).name === "string"
// ┌─── Effect<User, ParseError | ValidationError, never>// ▼const parseUser = (input: string) => Effect.try({ try: () => JSON.parse(input) as unknown, catch: (cause) => new ParseError(cause), }).pipe( Effect.flatMap((value) => isUser(value) ? Effect.succeed(value) : Effect.fail(new ValidationError()) ) )The error channel says ParseError | ValidationError. Recovery operators (step 09) can narrow this back down one tag at a time.
Your turn fill in the `Effect.try` parse step and the `isUser`-then-succeed-or-fail branch.
Common Pitfalls
1, runSync on an async effect. Use runPromise whenever the effect contains any asynchronous step.
2, Treating Effect.sync like Effect.succeed. succeed captures one value; sync re-runs its thunk on every execution. If the thunk has side effects, they happen on every run, every retry.
3, Letting JSON.parse infect the codebase with any. Cast to unknown and validate.
4, Throwing from inside Effect.sync. A thrown exception there becomes a defect, not a typed error. Use Effect.try when the operation can throw.
channels. - [ ] Complete `wrap-sync`: confirm the thunk re-runs on every execution. - [ ] Complete `wrap-throwing`: feed invalid JSON to `Effect.try` and map the exception into a `ParseError`. - [ ] Complete `runners`: pick the runner that returns a Promise and never throws. - [ ] Complete `compose`: chain `Effect.try` with `flatMap` so the error channel becomes `ParseError | ValidationError`. - [ ] Note how the constructor and the runner together form the seam between Effect and the rest of your program.
See Also
- Previous,
04-error-types: the tagged errors thatEffect.failandEffect.trypopulate. - Next,
06-pipe-and-flow: composing effects withpipeandflow. - Later,
09-error-handling: recovering from typed errors. - Later,
13-async-operations:Effect.promiseandEffect.tryPromise.
Run the tests: pnpm test 05-effect-basics
The full test suite
The index.test.ts for this lesson, verbatim from steps/05-effect-basics/.
import { it, expect } from "vite-plus/test"import { Effect, Exit } from "effect"
// =============================================================================// 03 - Effect Basics: Creating and Running Effects// =============================================================================//// An Effect<A, E, R> is an immutable, lazy description of a computation.// Creating one does nothing. A runner — Effect.runSync, Effect.runPromise,// or their *Exit variants — turns the description into execution.
// @beat// id: succeed-and-fail// shape: multi-todo// title: Express success and failure// teaches: Effect.succeed and Effect.fail lift known values into the success or error channel.// hint: use Effect.succeed for the value and Effect.fail for the errorit("Effect.succeed places a value in the A channel", () => { // ┌─── Effect<number, never, never> // ▼ const program = /* @stub */ Effect.succeed(42) /* @end */ expect(Effect.runSync(program)).toBe(42)})
it("Effect.fail places an error in the E channel", () => { class NotFound { readonly _tag = "NotFound" }
// ┌─── Effect<never, NotFound, never> // ▼ const program = /* @stub */ Effect.fail(new NotFound()) /* @end */ const exit = Effect.runSyncExit(program) expect(Exit.isFailure(exit)).toBe(true)})// @end-beat
// @beat// id: wrap-sync// shape: one-todo// title: Defer a synchronous side effect// teaches: Effect.sync wraps a thunk; the thunk runs on every execution.// hint: wrap the body in Effect.sync(() => { ... })it("Effect.sync defers and re-runs the thunk", () => { let counter = 0
// ┌─── Effect<number, never, never> // ▼ const increment = /* @stub */ Effect.sync(() => { counter += 1 return counter }) /* @end */
// Building the effect did nothing yet. expect(counter).toBe(0)
// Running it executes the thunk — every time. expect(Effect.runSync(increment)).toBe(1) expect(Effect.runSync(increment)).toBe(2) expect(counter).toBe(2)})// @end-beat
// @beat// id: wrap-throwing// shape: one-todo// title: Wrap code that may throw// teaches: Effect.try maps a thrown exception into a typed E value via the catch callback.// hint: Effect.try takes { try, catch }; the catch maps unknown to a typed errorit("Effect.try converts an exception into a typed error", () => { class ParseError { readonly _tag = "ParseError" constructor(readonly cause: unknown) {} }
// ┌─── Effect<unknown, ParseError, never> // ▼ const program = /* @stub */ Effect.try({ try: () => JSON.parse("{ invalid }") as unknown, catch: (cause) => new ParseError(cause), }) /* @end */
const exit = Effect.runSyncExit(program) expect(Exit.isFailure(exit)).toBe(true)})// @end-beat
// @beat// id: runners// shape: worked-then-your-turn// title: Pick a runner// teaches: runSync and runPromise throw on failure; the *Exit variants return a typed Exit instead.// hint: use Effect.runPromiseExit for the async pathit("runSync returns A directly (worked example)", () => { expect(Effect.runSync(Effect.succeed(1))).toBe(1)})
it("runSyncExit never throws — it returns an Exit (worked example)", () => { const success = Effect.runSyncExit(Effect.succeed(1)) expect(Exit.isSuccess(success)).toBe(true)
const failure = Effect.runSyncExit(Effect.fail("boom")) expect(Exit.isFailure(failure)).toBe(true)})
it("runPromiseExit: your turn — return an Exit from an async run", async () => { // ┌─── Promise<Exit<number, never>> // ▼ const exit = await /* @stub */ Effect.runPromiseExit(Effect.succeed(99)) /* @end */ expect(Exit.isSuccess(exit)).toBe(true)})// @end-beat
// @beat// id: sync-vs-async-runner// shape: read-run// title: runSync throws on async effects// teaches: Effect.runSync can only execute synchronous effects; calling it on any effect that contains an asynchronous step (Promise, sleep, async callback) throws at runtime.// hint: run the test and read the thrown message — it tells you to use runPromise insteadit("throws at runtime when the effect is asynchronous", () => { const asyncEffect = Effect.promise(() => Promise.resolve(42))
expect(() => Effect.runSync(asyncEffect)).toThrow()})
it("runPromise handles the same async effect without throwing", async () => { const asyncEffect = Effect.promise(() => Promise.resolve(42))
await expect(Effect.runPromise(asyncEffect)).resolves.toBe(42)})// @end-beat
// @beat// id: compose// shape: multi-todo// title: Compose — parse, then validate// teaches: Effect.flatMap chains an effect onto the success channel; the E channel widens to the union of all possible failures.// hint: Effect.try produces ParseError; the flatMap branch produces ValidationErrorit("composes Effect.try with a validation step", () => { type User = { readonly name: string }
class ParseError { readonly _tag = "ParseError" constructor(readonly cause: unknown) {} } class ValidationError { readonly _tag = "ValidationError" }
const isUser = (value: unknown): value is User => typeof value === "object" && value !== null && "name" in value && typeof (value as { name: unknown }).name === "string"
// ┌─── Effect<User, ParseError | ValidationError, never> // ▼ const parseUser = (input: string) => /* @stub */ Effect.try({ try: () => JSON.parse(input) as unknown, catch: (cause) => new ParseError(cause), }) /* @end */.pipe( Effect.flatMap((value) => /* @stub */ isUser(value) ? Effect.succeed(value) : Effect.fail(new ValidationError()) /* @end */ ) )
expect(Effect.runSync(parseUser('{"name":"Alice"}'))).toEqual({ name: "Alice" }) expect(Exit.isFailure(Effect.runSyncExit(parseUser("not json")))).toBe(true) expect(Exit.isFailure(Effect.runSyncExit(parseUser('{"name":42}')))).toBe(true)})// @end-beat