02 - Description vs Execution
A regular function call runs now. An Effect is a value that describes work to do — nothing happens until a runner executes it.
Almost every beginner stalls on this distinction once: “I called my Effect, why did nothing happen?” This step makes the difference observable so the gap closes before it bites.
What you’ll work through
index.test.ts contains six beats. Two are read-only (the JS baseline and the final composition); three are one-line stubs that ask you to call a runner; one is a mistake-first beat showing the forgotten-runner bug and its fix.
By the end you should be able to glance at any block of Effect code and say: has anyone actually run this?
Mental Model
Regular function Effect value─────────────────── ─────────────────── work() const w = work() │ │ ▼ runs immediately ▼ returns a description side effects fire Effect<A, E, R> │ ▼ pass to a runner Effect.runSync(w) │ ▼ now side effects fireA regular function is a thunk that runs. An Effect is a blueprint you can hand around, store, retry, log, or never run at all.
Execution is a separate step performed by a runner (runSync, runPromise, runFork, …).
A function runs the moment you call it
let called = 0const work = () => { called++ }
work() // side effect fires nowwork() // and again// called === 2There is no “create a function call value and run it later” — calling is running. The parentheses () are the action; you cannot hold a “call” as a value, only the function itself.
This is the contrast the rest of the step builds on, because Effect lets you put the call into a value too.
Why this matters. Plain functions can’t be retried, scheduled, forked, mocked, or deferred — they’ve already happened by the time you’ve named them. Every other beat in this step makes that limitation visible.
Your turn run the test and confirm calling fires the side effect twice. There is nothing to fix here — the read-run beat sets the baseline you'll compare every later Effect against.
An Effect is a description — declaring one does nothing
import { Effect } from "effect"
let fired = 0
// ┌─── Effect<void, never, never>// ▼const describe = Effect.sync(() => { fired++ })
// fired is still 0 — `describe` is a value, not an execution.Effect.sync does not call its function. It captures it inside a value.
The captured side effect runs only when something hands describe to a runner.
Why this matters. Every retry, every concurrent fork, every test double works because the description is just a value. Functions can’t be retried or forked the same way — they’ve already happened.
Rule of thumb. Reading
const x = Effect.sync(...)should feel like readingconst recipe = ...— paper, not cooking. The smell of the bug we’ll meet two beats down is treating the recipe like a meal.
Your turn assert `fired === 0` even though `describe` was constructed. The point of the assertion is to be surprised by it — the JavaScript line *looks* like work, but it isn't.
A runner is what makes it happen
import { Effect } from "effect"
let fired = 0const describe = Effect.sync(() => { fired++ })
Effect.runSync(describe)// fired === 1Effect.runSync (and its siblings runPromise, runPromiseExit, runFork) are the only things in Effect that actually perform work. Until then your program is a tree of intentions.
Think of runSync as the point where the description meets the real world — it walks the tree, fires each captured side effect in order, and returns the final value (or throws on failure).
Rule of thumb. Runners live at the edge of your program — usually in
main, in a handler, or in a test. The middle of the program is pure descriptions wired together withpipe,flatMap, andgen.
Your turn replace the `/* @stub */ Effect.runSync(describeWork) /* @end */` placeholder so the runner fires the description and the counter moves from 0 to 1.
Descriptions are reusable
import { Effect } from "effect"
const greet = Effect.sync(() => "hello")
const first = Effect.runSync(greet)const second = Effect.runSync(greet)// first === "hello", second === "hello"The same Effect value can be run any number of times — each run is fresh. This is what makes retry, repeat, and concurrent fan-out free: the description is immutable; the runner produces the side effects.
Compare this to a promise, which fires once at construction and caches the result forever — you cannot “retry” a Promise, only retry whatever built it.
Your turn call `Effect.runSync(work)` twice in the test (both stub lines) and confirm the counter reaches 2 — proof that one description produces N executions when handed to N runner calls.
The forgotten-runner mistake
import { Effect } from "effect"
let saved = false
const persist = Effect.sync(() => { saved = true })
persist // ← created, but never run// saved is still falseThe most common bug in new Effect code is creating an Effect at the top of a function and forgetting to yield*, pipe, or run it. The type system will not warn you — an unused Effect is just an unused value.
Rule of thumb. TypeScript will not flag a discarded
Effectvalue as suspicious — it’s just an unused expression. Linters (no-unused-expressions) can help catch the bare-name-on-a-line shape, but they won’t catch the more subtle “I built it and threw it away” inside a function.
Your turn the first assertion shows `saved === false` after `persist` sits alone on a line — read it as the bug. Then replace the stub with `Effect.runSync(persist)` so the second assertion sees `saved === true`.
Same value, different fate.
Composition stays lazy
import { Effect } from "effect"
const audit: Array<string> = []
const program = Effect.sync(() => { audit.push("step-a")}).pipe( Effect.flatMap(() => Effect.sync(() => { audit.push("step-b") })),)
// audit is still empty — no runner has touched `program` yet.
Effect.runSync(program)// audit === ["step-a", "step-b"]pipe and flatMap compose two descriptions into a larger description. That larger value is still inert — nothing runs until a runner touches it at the edge.
A program made of a hundred composed Effects is just one bigger description; one runSync walks the whole tree.
Your turn assert the audit array is empty after composing but before running, then assert it has both steps after the runner fires.
Common Pitfalls
1, Treating Effect.sync(f) as f(). It is not. Effect.sync(f) is a value that, when run, will invoke f.
Without a runner, f never executes.
2, “Why isn’t my logger firing?” Almost always: the Effect that performs the log was constructed but not handed to a runner, or it was created inside a gen block but not yield*-ed.
3, Re-running for side effects, expecting state. A description holds a closure, not state. Each runner invocation is a fresh execution — Refs and other state cells exist for sharing values across a single run, not across runs (covered in step 39).
Rules of thumb
- If side effects must happen, a runner must touch the value. No runner, no execution.
- An Effect value can be stored in a
const, returned, passed to functions, and held for later. Nothing runs until you hand it to a runner. - Construction is free. Execution is the cost.
pass first. - [ ] In `index.test.ts`, replace the `/* @stub */ Effect.runSync(...) /* @end */` blocks. Three beats are one-line stubs; the mistake-first beat has two. - [ ] After each replacement, re-run the test for that beat and watch the counter, the boolean, or the audit array move. - [ ] Read the final `composed-still-lazy` beat. It composes two Effects with `pipe` and `flatMap` and runs them together — confirm the audit array stays empty until the runner fires.
See Also
- Previous,
01-the-problem: whytry/catchfalls short. - Next,
03-result-types: theEffect<A, E, R>type signature. - Later,
05-effect-basics: the four constructors (succeed,fail,sync,promise) and the runners (runSync,runPromise). - Later,
14-runners: the full menu of runners and when to choose which.
Run the tests: pnpm test 02-description-vs-execution
The full test suite
The index.test.ts for this lesson, verbatim from steps/02-description-vs-execution/.
import { it, expect } from "vite-plus/test"import { Effect } from "effect"
// =============================================================================// 02 - Description vs Execution// =============================================================================//// A regular function runs when called. An Effect is a value that describes// work; nothing happens until a runner executes it. This lesson makes the// distinction observable.
// @beat// id: functions-run-immediately// shape: read-run// title: A function runs the moment you call it// teaches: Calling a regular function is the same act as running it; there is no "describe now, run later" for plain JS calls.// hint: each call to work() fires the side effect, so the counter equals the number of callsit("a regular function call fires its side effect immediately", () => { let called = 0 const work = () => { called++ }
work() work()
expect(called).toBe(2)})// @end-beat
// @beat// id: effects-are-descriptions// shape: one-todo// title: Constructing an Effect does not run it// teaches: Effect.sync captures a function inside a value; the captured function is not invoked until a runner walks the description.// hint: the body of Effect.sync hasn't been executed yet — predict the counterit("Effect.sync captures the work without running it", () => { let fired = 0
const describeWork = Effect.sync(() => { fired++ })
// Predict the counter value before any runner has been applied. expect(fired).toBe(/* @stub */ 0 /* @end */)
// The Effect value is still usable as a value (we can name it, return it, hand it around). expect(typeof describeWork).toBe("object")})// @end-beat
// @beat// id: runner-triggers-execution// shape: one-todo// title: A runner is what makes it happen// teaches: Runners (runSync, runPromise, runPromiseExit, runFork) are the only thing in Effect that performs the work captured by a description.// hint: Effect.runSync(describeWork) — and watch the counter moveit("Effect.runSync executes the described work", () => { let fired = 0 const describeWork = Effect.sync(() => { fired++ })
/* @stub */ Effect.runSync(describeWork) /* @end */
expect(fired).toBe(1)})// @end-beat
// @beat// id: descriptions-are-reusable// shape: one-todo// title: The same description can be run any number of times// teaches: An Effect value is immutable; each runner invocation produces a fresh execution. This is what makes retry and repeat free.// hint: call Effect.runSync(work) twice — the counter should reach 2it("re-running a description produces a fresh execution each time", () => { let count = 0 const work = Effect.sync(() => { count++ })
/* @stub */ Effect.runSync(work) /* @end */ /* @stub */ Effect.runSync(work) /* @end */
expect(count).toBe(2)})// @end-beat
// @beat// id: forgotten-runner// shape: mistake-first// title: The forgotten-runner bug// teaches: An Effect that is never handed to a runner is just an unused value. If your side effects never fire, look first for an Effect that was constructed but never run.// hint: the first assertion shows the bug; the second adds the runner and confirms the fixit("an Effect that is never run does nothing — even though it was 'called'", () => { let saved = false const persist = Effect.sync(() => { saved = true })
// ❌ Common mistake — Effect is created but never handed to a runner. persist
expect(saved).toBe(false)
// ✅ Fix — actually run it. /* @stub */ Effect.runSync(persist) /* @end */
expect(saved).toBe(true)})// @end-beat
// @beat// id: composed-still-lazy// shape: multi-todo// title: Composed programs are still just descriptions// teaches: pipe and flatMap compose descriptions into larger descriptions; nothing runs until a runner is applied at the edge.// hint: predict the audit before any runner fires, then after both steps execute in orderit("composing Effects builds a larger description — still no execution", () => { const audit: Array<string> = []
const program = Effect.sync(() => { audit.push("step-a") }).pipe( Effect.flatMap(() => Effect.sync(() => { audit.push("step-b") }), ), )
// Predict the audit array before the runner has touched the program. expect(audit).toEqual(/* @stub */ [] /* @end */)
Effect.runSync(program)
// Predict the audit array after the composed program runs to completion. expect(audit).toEqual(/* @stub */ ["step-a", "step-b"] /* @end */)})// @end-beat