Effect v4Module 1 of 8: Describe before execution
Module 1 of 8Work modelsrc/course/examples/blueprint.ts

Describe the work before you run it

Separate the operation you can compose from the moment it starts.

First principle

A value can describe work without doing it. Execution is a separate decision.

01 / 08module position
01

The concrete friction

Calling an async function starts its body immediately

In JavaScript, calling an async function immediately allocates an execution frame on the call stack and executes synchronously up to the first await. It returns an already-running Promise on the event loop. If that body calls a network provider, the request begins before any caller can decide to retry it, time it out, inject a test double, or cancel it. Function factories (() => searchIssues()) delay the call, but an opaque closure cannot expose typed failures, required services, or composable execution policies.

First-principles consequence

A Promise represents work that has already started. You cannot attach policies (retries, timeouts, resource boundaries) to an in-flight operation. To compose policies, the computation must remain an inert, referentially transparent value until an explicit execution boundary.

baseline-friction.tsbaseline problem
async function searchIssues(query: string): Promise<string> {
  const response = await fetch(
    `/api/issues?q=${encodeURIComponent(query)}`
  );
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return `search completed for ${query}`;
}

// Calling the helper starts the network request immediately.
const firstRequest = searchIssues("effect");

// A function factory delays the call, but it is still only an unverified convention.
const requestFactory = () => searchIssues("effect");
const secondRequest = await requestFactory();
02

The mental model

An Effect is an immutable blueprint of computation

Calling searchIssues creates an Effect value: an immutable data structure describing the steps to take. It does not invoke fetch, allocate sockets, or schedule microtasks. Execution happens only when the program reaches an explicit execution boundary (such as Effect.runPromise). Because the blueprint is a pure value, it can be executed zero, one, or multiple times independently.

Create the description:
   const program = searchIssues("effect")
   requestsStarted = 0

Run the description:
   await Effect.runPromise(program)
   requestsStarted = 1
   result = ISSUE-101

Run the same description again:
   await Effect.runPromise(program)
   requestsStarted = 2
   result = ISSUE-101
03

Minimal working example

Executable source

This is the checked source file used by the course. Read it before you run it.

src/course/examples/blueprint.tsWork model
import { Console, Effect } from "effect"

export type Issue = {
  readonly id: string
  readonly title: string
}

let requestsStarted = 0

const requestIssueSearch = async (query: string): Promise<ReadonlyArray<Issue>> => {
  requestsStarted += 1
  return [{ id: "ISSUE-101", title: `Search result for ${query}` }]
}

export const searchIssues = (query: string) =>
  Effect.tryPromise({
    try: () => requestIssueSearch(query),
    catch: (cause) => String(cause)
  })

const program = searchIssues("effect")

await Effect.runPromise(
  Effect.gen(function* () {
    yield* Console.log(`before run: ${requestsStarted} requests`)

    const firstRun = yield* program
    yield* Console.log(
      `after first run: ${requestsStarted} request, first result = ${firstRun[0]?.id}`
    )

    const secondRun = yield* program
    yield* Console.log(
      `after second run: ${requestsStarted} requests, first result = ${secondRun[0]?.id}`
    )
  })
)
04

Execute and verify

Predict the result, run the command, and compare the output with the model.

Run this command
pnpm exec tsx src/course/examples/blueprint.ts
Before you run it

Before you run it, predict requestsStarted after creating program, after the first run, and after the second run. Why does running the same description again start another request?

Reveal expected output
before run: 0 requests
after first run: 1 request, first result = ISSUE-101
after second run: 2 requests, first result = ISSUE-101
05

Error anatomy and edge cases

The eager evaluation trap: evaluate the Promise outside the Effect

Writing Effect.succeed(requestIssueSearch("effect")) calls requestIssueSearch while the program is being assembled. Effect.succeed stores the already-running Promise as a successful value; it does not make the Promise lazy.

Compiler or runtime diagnostic

TypeScript types the result as Effect<Promise<ReadonlyArray<Issue>>, never, never>. Downstream callers expecting Issue[] encounter: "TS2339: Property 'map' does not exist on type 'Promise<ReadonlyArray<Issue>>'". At runtime, network requests fire immediately upon file load, incrementing request counters before Effect.runPromise executes.

Remedy

Use Effect.tryPromise({ try: () => requestIssueSearch(query) }) so each execution creates a fresh Promise. Use Effect.sync(() => computation()) for synchronous work. Never pass a raw Promise directly to Effect.succeed.

06

Active lab exercise

src/exercises/exercise1/starter.ts

Exercise: defer the request until execution

Move the request call into the Effect so assembly starts no requests.

ObjectiveFix starter.ts so requestsStarted stays at zero until searchIssue runs.
pnpm exec tsx src/exercises/exercise1/exercise.test.ts starter

The harness should fail against the starter. Inspect the assertion, change the starter implementation, and run it again.

Inspect starter code
Broken starter implementation
src/exercises/exercise1/starter.tsstarter
import { Effect } from "effect"

let requestsStarted = 0

const requestIssueSearch = () => {
  requestsStarted += 1
  return "ISSUE-101"
}

const eagerResult = requestIssueSearch()

export const getRequestsStarted = () => requestsStarted
export const searchIssue = Effect.succeed(eagerResult)
Reveal reference solution
Target reference solution
solution.tsverified solution
import { Effect } from "effect"

let requestsStarted = 0

const requestIssueSearch = () => {
  requestsStarted += 1
  return "ISSUE-101"
}

export const getRequestsStarted = () => requestsStarted
export const searchIssue = Effect.sync(requestIssueSearch)
Why this solution works

Use Effect.sync(requestIssueSearch) so the request function runs when the Effect executes. Effect.succeed receives an already-computed value, so it cannot defer the call.