The concrete friction
Sequencing Promises forces immediate execution of the first step
Computation B often depends on the output of computation A (e.g. loading an issue requires the issue ID found by search). In vanilla async/await, writing const id = await findId(); return load(id); immediately executes findId(). The two steps cannot be combined into a single inert pipeline value that can be handed to a retry schedule, concurrency throttler, or scoped boundary.
Causality requires step A to complete before step B begins. But coupling sequencing to immediate execution destroys composition: you cannot retry the sequence (A -> B) as an atomic unit or pass the un-started workflow across architectural layers.
const findIssueId = async (): Promise<string> => "ISSUE-101";
const loadIssue = async (issueId: string) => ({
id: issueId,
title: "Add typed search errors"
});
const issueIdPromise = findIssueId();
const issuePromise = issueIdPromise.then((issueId) => loadIssue(issueId));
const issue = await issuePromise;The mental model
Generator coroutines suspend execution algebraically
Effect.gen uses JavaScript generator syntax (function* and yield*) to express data dependency without eager execution. The expression yield* findIssueId suspends the coroutine until findIssueId produces its success value (A). The runtime then resumes the generator, binding the value to issueId and passing it to loadIssue(issueId). The entire generator block remains a single un-evaluated Effect value until run.
Assemble one program:
const program = Effect.gen(function* () { ... })
No step has run yet.
Execute the program:
findIssueId ──> "ISSUE-101"
│
▼
loadIssue("ISSUE-101") ──> Issue
The second step receives the first step's value.Minimal 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
}
const findIssueId = Effect.sync(() => "ISSUE-101")
const loadIssue = (issueId: string) =>
Effect.sync<Issue>(() => ({
id: issueId,
title: "Add typed search errors"
}))
const program = Effect.gen(function* () {
const issueId = yield* findIssueId
const issue = yield* loadIssue(issueId)
return issue
})
await Effect.runPromise(
Effect.gen(function* () {
yield* Console.log("The program is assembled before either step runs")
const issue = yield* program
yield* Console.log(`loaded ${issue.id}: ${issue.title}`)
})
)Execute and verify
Predict the result, run the command, and compare the output with the model.
pnpm exec tsx src/course/examples/sequence.tsWhich value does loadIssue receive, and when does it receive it? Why is the second operation defined as a function of issueId?
Reveal expected output
The program is assembled before either step runs
loaded ISSUE-101: Add typed search errorsError anatomy and edge cases
Passing an Effect blueprint where a raw value is expected
Calling loadIssue(findIssueId) without yielding findIssueId passes the Effect blueprint itself rather than the resolved issue ID string.
TypeScript reports: "TS2345: Argument of type 'Effect<string, never, never>' is not assignable to parameter of type 'string'". If yield* is called outside of an Effect.gen generator function, TypeScript reports: "TS1160: The 'yield' expression is only allowed within the body of a generator function".
Use const issueId = yield* findIssueId inside Effect.gen to extract the success value from the Effect before passing it to loadIssue(issueId).
Active lab exercise
Exercise: sequence dependent issue operations
Pass the issue ID produced by the first operation to the second operation.
pnpm exec tsx src/exercises/exercise2/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"
type Issue = {
readonly id: string
readonly title: string
}
const findIssueId = Effect.succeed("ISSUE-101")
const loadIssue = (issueId: string) =>
Effect.succeed<Issue>({
id: issueId,
title: "Add typed search errors"
})
export const loadIssueAfterSearch = Effect.gen(function* () {
yield* findIssueId
return yield* loadIssue("ISSUE-999")
})Reveal reference solution
import { Effect } from "effect"
type Issue = {
readonly id: string
readonly title: string
}
const findIssueId = Effect.succeed("ISSUE-101")
const loadIssue = (issueId: string) =>
Effect.succeed<Issue>({
id: issueId,
title: "Add typed search errors"
})
export const loadIssueAfterSearch = Effect.gen(function* () {
const issueId = yield* findIssueId
return yield* loadIssue(issueId)
})Bind the result with const issueId = yield* findIssueId, then pass issueId to loadIssue. The second Effect is created only after the first Effect produces its value.