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.
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.
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();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-101Minimal working example
Executable source
This is the checked source file used by the course. Read it before you run it.
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}`
)
})
)Execute and verify
Predict the result, run the command, and compare the output with the model.
pnpm exec tsx src/course/examples/blueprint.tsBefore 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-101Error 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.
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.
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.
Active lab exercise
Exercise: defer the request until execution
Move the request call into the Effect so assembly starts no requests.
pnpm exec tsx src/exercises/exercise1/exercise.test.ts starterThe harness should fail against the starter. Inspect the assertion, change the starter implementation, and run it again.
Inspect starter code
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
import { Effect } from "effect"
let requestsStarted = 0
const requestIssueSearch = () => {
requestsStarted += 1
return "ISSUE-101"
}
export const getRequestsStarted = () => requestsStarted
export const searchIssue = Effect.sync(requestIssueSearch)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.