Code Style #
gen or pipe, data-first or data-last, Effect.fn, branded ids, Match instead of switch, tagged enums, and 1 run call at the edge.
The problem. You can write Effect code badly. The most common bad shape is a helper class. Its methods run effects from the inside, all ids are string, and a switch ignores a new case without a warning:
class OrderService {
getTotal(orderId: string, userId: string) {
const order = Effect.runSync(this.repo.find(orderId)) // runs in the middle of business logic
switch (order.status) {
case "paid": return order.total
case "pending": return 0
// "refunded" was added last week; this returns undefined now
}
}
}
service.getTotal(userId, orderId) // arguments swapped, compiles fineEvery line compiles. Every line is a bug that waits for production. The runSync throws as soon as find becomes async. You can exchange the 2 string parameters without a compile error. The switch cannot tell you that it is incomplete.
The shift
Today you think of a program as objects with methods. The important rules are in comments and tests. In Effect, you write small named functions that return effects, and the types hold the rules. A function accepts an OrderId, not a string, so exchanged arguments are a compile error. A state is a tagged union, and Match.exhaustive refuses to compile until you process every case. You build effects with Effect.gen for sequences and with pipe for transformations. You run them 1 time, at the edge of the program. The result is that most review comments become type errors. The code reads from top to bottom as a description of what it does.
| Situation | Use | Do not use |
|---|---|---|
| Steps that use earlier results, branches, loops | Effect.gen with yield* |
Nested flatMap |
| A straight line of transformations on 1 value | .pipe(Effect.map, Effect.tap, ...) |
A gen with 1 yield* and a return |
| A reusable function with parameters | Effect.fn("name")(function* (...) {}) |
A plain function that wraps Effect.gen |
| Ids and units that must not mix | Brand.Branded<string, "UserId"> |
string and number everywhere |
| Process every case of a union | Match.type<T>() + Match.exhaustive |
switch without a default |
| A value with several shapes | Data.taggedEnum |
{ status: string; data?: unknown } |
| Run an effect | 1 time, at the edge (runPromise in main) |
runSync inside a map or a method |
The rule for depth: do not put a gen inside a pipe inside a gen. If you see this shape, move the inner part into a named function and call it.
Learn #
Lesson 1. Effect.gen or pipe? Select by shape #
Both build the same type of value: an effect. They differ in what they make easy to read.
Use Effect.gen when |
Use pipe when |
|---|---|
| A later step needs a value from an earlier step | Each step needs only the previous result |
There is an if, an early return, or a loop |
The steps are a straight line |
| You want named intermediate values | The intermediate values do not need names |
The code must read like async/await |
The code must read like array method chains |
The bad pattern is a mix at depth:
Effect.gen(function* () {
const x = yield* load().pipe(
Effect.flatMap((a) => Effect.gen(function* () { ... })) // gen in pipe in gen: stop
)
})When you see this shape, move the inner gen into a named function. In the program below, subtotal is a straight line, so it is a pipe. total has a branch and uses order 2 times, so it is a gen. Each function is small enough to read in 1 look.
import { Effect } from "effect"
interface Order { readonly id: number; readonly items: ReadonlyArray<number>; readonly coupon?: string }
const loadOrder = (id: number) => Effect.succeed<Order>({ id, items: [20, 15, 5], coupon: "SAVE10" })
const couponDiscount = (code: string | undefined) => Effect.succeed(code === "SAVE10" ? 10 : 0)
// pipe: a straight line of transformations. No branching, no names for intermediate values.
const subtotal = (order: Order) =>
Effect.succeed(order.items).pipe(
Effect.map((items) => items.reduce((a, b) => a + b, 0)),
Effect.map((sum) => Math.round(sum * 100) / 100)
)
// gen: steps that depend on earlier results, with a branch in the middle.
const total = (id: number) =>
Effect.gen(function* () {
const order = yield* loadOrder(id)
const sum = yield* subtotal(order)
if (sum < 30) return sum // small orders: no discount, stop here
const discount = yield* couponDiscount(order.coupon)
return sum - discount
})
console.log("total:", Effect.runSync(total(1)))
Try to write total as a pipe. You need flatMap to keep order in scope for the discount step, and the early return becomes a nested ternary. That difficulty is the signal. Now try to write subtotal as a gen. It works, but you must invent 2 variable names for values that nobody reads a second time.
Lesson 2. Data-first and data-last: read the signatures #
Almost every Effect function has 2 forms. This is called a dual API:
export const map: {
<A, B>(f: (a: A) => B): <E, R>(self: Effect<A, E, R>) => Effect<B, E, R> // data-last
<A, E, R, B>(self: Effect<A, E, R>, f: (a: A) => B): Effect<B, E, R> // data-first
}The first overload accepts only the function. It returns a new function that waits for the effect. .pipe uses that form. The second overload accepts the effect (the "data") as the first argument and does the work at once.
| Form | Example | Best for |
|---|---|---|
| Data-first | Effect.map(effect, f) |
A single call, when there is no chain |
| Data-last | effect.pipe(Effect.map(f)) |
A chain of several steps |
| Data-last, stored | const addTax = Effect.map(...) |
A named transformation that you use again |
When you read the docs and see an overload list, find the overload whose first parameter is self. That overload is data-first. The other overload is the pipeable form. Both produce the same effect.
import { Effect, pipe } from "effect"
const double = (n: number) => n * 2
const a = Effect.map(Effect.succeed(21), double) // data-first: the Effect is the first argument
const b = Effect.succeed(21).pipe(Effect.map(double)) // data-last: only the function, then .pipe
const c = pipe(Effect.succeed(21), Effect.map(double)) // data-last with the standalone pipe function
console.log(Effect.runSync(a), Effect.runSync(b), Effect.runSync(c))
// Only the data-last form gives you a reusable, named transformation
const addTax = Effect.map((price: number) => price * 1.2)
console.log(Effect.runSync(addTax(Effect.succeed(100))))
console.log(Effect.runSync(Effect.succeed(50).pipe(addTax)))
addTax is a plain function from an effect to an effect. You can pass it as an argument, put it in a list, or apply it in a pipe. Try to write it data-first. You cannot, because there is no effect to put first. This is why libraries expose data-last forms. They compose.
Lesson 3. Effect.fn: named functions with parameters #
A function that accepts parameters and returns an effect is the most common unit of Effect code. The basic version is an arrow function that wraps Effect.gen:
const findUser = (id: number) => Effect.gen(function* () { ... }) // works, but has no name in tracesEffect.fn("findUser") does the same with 3 additions. The generator receives the parameters directly. The name appears in stack traces, and the function makes a tracing span. A span is a named, timed record of 1 operation. A slow findUser is then visible by name in your observability tool. Extra arguments after the generator are applied to the result, like pipe steps. The original parameters are also available to them. Put error handlers and annotations there. Caution: do not call .pipe on the function itself. A function has no pipe.
Effect.fnUntraced is the same without the span. Use it for small helpers and for hot paths, where a span for each call is noise. Use fn for an operation that a person wants to see in a trace.
To state the return type, annotate the generator with Effect.fn.Return<A, E, R>. Example: function* (id: number): Effect.fn.Return<string, NotFound> { ... }.
import { Effect, Schema } from "effect"
class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { id: Schema.Number }) {}
const users: Record<number, string> = { 1: "Ada", 2: "Lin" }
// Effect.fn: a generator body with parameters, plus a name for traces and spans
const findUser = Effect.fn("findUser")(function* (id: number) {
const name = users[id]
if (name === undefined) return yield* new NotFound({ id })
return name
})
// Extra arguments are applied to the result like pipe steps. No .pipe on the function itself.
const findUserOrGuest = Effect.fn("findUserOrGuest")(
function* (id: number) {
return yield* findUser(id)
},
Effect.catchTag("NotFound", (e) => Effect.succeed("guest-" + e.id))
)
// fnUntraced: same ergonomics, no span. For small helpers and hot paths.
const shout = Effect.fnUntraced(function* (name: string) {
return name.toUpperCase() + "!"
})
const program = Effect.gen(function* () {
console.log(yield* findUser(1))
console.log(yield* findUserOrGuest(9))
console.log(yield* shout(yield* findUser(2)))
})
Effect.runSync(program)
Point at findUser in an editor. Its type is (id: number) => Effect<string, NotFound>, the same as the arrow version. Callers do not know which style you used. Remove the return before yield* new NotFound({ id }). TypeScript then thinks that the function continues, and the return type includes undefined. Always write return when you yield an error.
Lesson 4. Branded types: make a wrong argument a type error #
UserId and OrderId are both strings at run time. That is the problem. loadOrder(userId, orderId) compiles with the arguments exchanged. A brand adds a compile-time tag to a base type. The value is still a plain string, but TypeScript treats UserId and OrderId as different types.
type UserId = Brand.Branded<string, "UserId">
const UserId = Brand.nominal<UserId>() // constructor: UserId("u-1")The convention is to use the same name for the type and the constructor. Brand.nominal adds no run-time check. Brand.make accepts a predicate and gives a constructor that validates. A call with bad input throws. The constructor also has .is, .option, and .result for checks that do not throw.
The commented line in the code produces this error if you enable it:
Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.That is the purpose. You cannot write the invalid state, so it cannot reach production.
import { Brand, Result } from "effect"
// Two brands over the same base type. At runtime both are plain strings.
type UserId = Brand.Branded<string, "UserId">
type OrderId = Brand.Branded<string, "OrderId">
const UserId = Brand.nominal<UserId>() // no runtime check, only a type tag
const OrderId = Brand.nominal<OrderId>()
// A validated brand: the constructor enforces the rule
type Quantity = Brand.Branded<number, "Quantity">
const Quantity = Brand.make<Quantity>((n) => Number.isInteger(n) && n > 0)
const describeOrder = (order: OrderId, owner: UserId) => "order " + order + " belongs to " + owner
const userId = UserId("u-1")
const orderId = OrderId("o-9")
console.log(describeOrder(orderId, userId))
// describeOrder(userId, orderId) // type error: 'UserId' is not assignable to 'OrderId'
console.log("is 3 a quantity?", Quantity.is(3), "is -2?", Quantity.is(-2))
const checked = Quantity.result(0.5)
console.log(Result.isSuccess(checked) ? "quantity " + checked.success : "rejected 0.5")
Enable the commented call and read the error. Then try describeOrder("o-9", userId). A raw string is rejected too, because only the constructor can produce an OrderId. Every place that makes an id becomes visible. That is where validation belongs.
Lesson 5. Match instead of switch #
A switch on _tag works until a person adds a case. The new case has no branch, the switch returns undefined, and nobody sees it. The Match module builds the same decision as a value. Match.exhaustive refuses to compile while a case is not processed.
switch |
Match |
|---|---|
| A statement. You need a variable to collect the result | An expression. It returns the result |
A missing case returns undefined without a warning |
A missing case is a compile error with exhaustive |
| Only equality on 1 field | Tags, predicates, and partial object shapes |
| Not reusable | Match.type<T>() builds a reusable function |
There are 2 entry points. Match.type<T>() builds a function (t: T) => result that you can pass as an argument. Match.value(x) matches 1 value at once and gives the result. Add cases with Match.tag("Name", handler) for tagged unions. Add cases with Match.when(pattern, handler) for predicates or partial shapes. End with Match.exhaustive when all cases must be processed. End with Match.orElse(fallback) when a default is acceptable. Note: the matcher tries the cases in order, and the first match wins.
import { Match } from "effect"
type Event =
| { readonly _tag: "Deposit"; readonly amount: number }
| { readonly _tag: "Withdraw"; readonly amount: number }
| { readonly _tag: "Close" }
// Match.type builds a reusable function. exhaustive turns a missing case into a compile error.
const describe = Match.type<Event>().pipe(
Match.tag("Deposit", (e) => "+" + e.amount),
Match.tag("Withdraw", (e) => "-" + e.amount),
Match.tag("Close", () => "closed"),
Match.exhaustive
)
// Match.value matches one value right away. when() takes a predicate or a partial shape.
const grade = (score: number) =>
Match.value(score).pipe(
Match.when((n: number) => n >= 90, () => "A"),
Match.when((n: number) => n >= 75, () => "B"),
Match.orElse(() => "C")
)
const events: Array<Event> = [{ _tag: "Deposit", amount: 50 }, { _tag: "Withdraw", amount: 20 }, { _tag: "Close" }]
console.log(events.map(describe).join(" "))
console.log([95, 80, 40].map(grade).join(" "))
Remove the Close line in describe. The error is on Match.exhaustive. It says that the Close shape is not assignable to never, which means "not processed". Add a fourth event type, and you get the same error at compile time in every matcher that does not process it.
Lesson 6. Tagged enums, small effects, 1 run call at the edge #
When you write a tagged union by hand, you also write the constructors, the type guards, and the matcher. Data.taggedEnum makes all 3 from 1 type: a constructor for each case, $is("Case") guards, and an exhaustive $match.
The second half of this lesson is about shape. This is the version that you must not write:
const lifecycle = Effect.succeed(Pending({ amount: 40 })).pipe(
Effect.map((p) => Effect.runSync(charge(p))), // runSync inside business logic
Effect.flatMap((paid) => Effect.gen(function* () { // gen inside pipe
const refunded = yield* refund(paid)
return [paid, refunded].map((x) => Effect.runSync(describeEffect(x)))
}))
)Every runSync in the middle is a place where a future async step causes a crash. A test cannot replace a layer there, because the effect already ran. The correction is always the same. Move the work into small named effects (charge, refund). Sequence them in 1 flat gen. Run the result 1 time, at the end. Effect.runSync and Effect.runPromise belong in main, in a test, or at a framework boundary. They do not belong in a service method.
import { Data, Effect } from "effect"
type Payment = Data.TaggedEnum<{
Pending: { readonly amount: number }
Paid: { readonly amount: number; readonly receipt: string }
Refunded: { readonly amount: number }
}>
const { Pending, Paid, Refunded, $is, $match } = Data.taggedEnum<Payment>()
// Small, named effects. Each one does a single transition.
const charge = (p: Payment) =>
$is("Pending")(p)
? Effect.succeed(Paid({ amount: p.amount, receipt: "r-" + p.amount }))
: Effect.fail("cannot charge a " + p._tag + " payment")
const refund = (p: Payment) =>
$is("Paid")(p)
? Effect.succeed(Refunded({ amount: p.amount }))
: Effect.fail("cannot refund a " + p._tag + " payment")
// $match is exhaustive: add a fourth state and this stops compiling until you handle it
const describe = $match({
Pending: (p) => "pending " + p.amount,
Paid: (p) => "paid " + p.amount + " (receipt " + p.receipt + ")",
Refunded: (p) => "refunded " + p.amount
})
const lifecycle = Effect.gen(function* () {
const started = Pending({ amount: 40 })
const paid = yield* charge(started)
const refunded = yield* refund(paid)
return [started, paid, refunded].map(describe)
})
// One run call, at the edge of the program
for (const line of Effect.runSync(lifecycle)) console.log(line)
Note: $is("Pending")(p) narrows p, so p.amount is typed in the true branch. Exchange the 2 yield* lines. refund(started) fails with a clear message and does not corrupt the state, because each transition checks the tag that it accepts.
Do and don't #
| Do | Don't | Why |
|---|---|---|
Use Effect.gen when a step needs a value from an earlier step or when there is a branch. | Do not write nested Effect.flatMap closures to keep an earlier value in scope. | Each nested closure adds 1 level of depth, and the code becomes hard to read and to change. |
Move an inner Effect.gen out of a pipe into a named function. | Do not put a gen inside a pipe inside a gen. | The depth hides the sequence of steps, and a reader cannot see which value flows where. |
Write Effect.map(effect, f) for a single call and effect.pipe(Effect.map(f)) for a chain. | Do not write Effect.map(f, effect). | The data-first overload expects the effect as the first argument, so the call does not compile. |
Pass error handlers as extra arguments to Effect.fn, after the generator. | Do not call .pipe on the function that Effect.fn returns. | Effect.fn returns a function, and a function has no pipe, so the code does not compile. |
Write return yield* new NotFound({ id }) when you yield an error in a generator. | Do not write yield* new NotFound({ id }) without return. | TypeScript thinks that the function continues after the error, and the return type includes undefined. |
Make an id type with Brand.Branded<string, "UserId"> and a constructor with Brand.nominal. | Do not use plain string for 2 different types of id in 1 function signature. | The compiler accepts exchanged arguments, and the code reads the wrong record without a warning. |
End a matcher over a union with Match.exhaustive. | Do not use a switch without a default for a union that can grow. | A new case in the union returns undefined from the switch, and the compiler does not report it. |
Run an effect 1 time, at the edge, with Effect.runPromise in main or in a test. | Do not call Effect.runSync inside Effect.map or inside a service method. | runSync throws as soon as the inner effect has an async step, and a test layer cannot reach the inner effect. |
Fix it #
Each program below is broken or incomplete. Make it print the expected output with zero type errors. Use hints before the solution.
1. Which argument comes first? #
This program must print 42, but it does not compile. Correct the call to Effect.map. Do not change to .pipe.
import { Effect } from "effect"
const double = (n: number) => n * 2
const program = Effect.map(double, Effect.succeed(21))
console.log(Effect.runSync(program))
2. Exchanged ids #
The program must print you own o-1, but it does not compile. Correct the call to ownerOf. Do not change the brands or the function.
import { Brand } from "effect"
type UserId = Brand.Branded<string, "UserId">
type OrderId = Brand.Branded<string, "OrderId">
const UserId = Brand.nominal<UserId>()
const OrderId = Brand.nominal<OrderId>()
const orders: Record<string, { owner: UserId; total: number }> = {
"o-1": { owner: UserId("u-1"), total: 30 }
}
const ownerOf = (orderId: OrderId, requester: UserId) => {
const order = orders[orderId]
if (order === undefined) return "no such order"
return order.owner === requester ? "you own " + orderId : "not your order"
}
const me = UserId("u-1")
const myOrder = OrderId("o-1")
console.log(ownerOf(me, myOrder))
3. The case that was added last week #
A Line shape was added to the union, and now the matcher does not compile. Make it process every shape, so that the program prints 13 9 0. A line has an area of 0.
import { Match } from "effect"
type Shape =
| { readonly _tag: "Circle"; readonly radius: number }
| { readonly _tag: "Square"; readonly side: number }
| { readonly _tag: "Line"; readonly length: number }
const area = Match.type<Shape>().pipe(
Match.tag("Circle", (s) => Math.round(Math.PI * s.radius * s.radius)),
Match.tag("Square", (s) => s.side * s.side),
Match.exhaustive
)
const shapes: Array<Shape> = [{ _tag: "Circle", radius: 2 }, { _tag: "Square", side: 3 }, { _tag: "Line", length: 5 }]
console.log(shapes.map(area).join(" "))
4. runSync in the middle #
The basket total stops with an error at run time. Change basketTotal so that the prices are read inside the effect pipeline, without a runSync. The program must print total: 8.
import { Effect } from "effect"
// Prices come from an async source
const fetchPrice = (sku: string) => Effect.promise(() => Promise.resolve(sku === "apple" ? 3 : 5))
const basketTotal = (skus: ReadonlyArray<string>) =>
Effect.succeed(skus).pipe(
Effect.map((list) => list.map((sku) => Effect.runSync(fetchPrice(sku)))),
Effect.map((prices) => prices.reduce((a, b) => a + b, 0))
)
Effect.runPromise(basketTotal(["apple", "pear"])).then((total) => console.log("total:", total))
5. The first match wins #
The status labels are wrong. 404 and 503 must get their specific labels. Only the other 4xx and 5xx codes must be error. Change the order of the cases, so that the program prints ok, not found, try again later, error.
import { Match } from "effect"
const label = (status: number) =>
Match.value(status).pipe(
Match.when((s: number) => s >= 400, () => "error"),
Match.when(404, () => "not found"),
Match.when(503, () => "try again later"),
Match.orElse(() => "ok")
)
console.log([200, 404, 503, 418].map(label).join(", "))
6. A function has no pipe #
The author tried to attach an error handler to an Effect.fn with .pipe, and the program does not compile. Move the handler to the position that Effect.fn expects. The program must print Ada and then guest.
import { Effect, Schema } from "effect"
class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { id: Schema.Number }) {}
const users: Record<number, string> = { 1: "Ada" }
const findUserOrGuest = Effect.fn("findUserOrGuest")(function* (id: number) {
const name = users[id]
if (name === undefined) return yield* new NotFound({ id })
return name
}).pipe(Effect.catchTag("NotFound", () => Effect.succeed("guest")))
const program = Effect.gen(function* () {
console.log(yield* findUserOrGuest(1))
console.log(yield* findUserOrGuest(2))
})
Effect.runSync(program)
Build it #
Write the program from the spec. The output must match exactly.
1. Typed ids and a named lookup #
Change the loose lookup into typed, named code.
- Make
UserIdandOrderIdnominal brands overstring. Use the same name for the type and the constructor. - Write
getOrderwithEffect.fn("getOrder"). It accepts(orderId: OrderId, requester: UserId). It fails withNotFoundwhen the order does not exist. It fails withForbidden(withrequesterasuserId) when the order belongs to a different user. Otherwise it returns the order. - Write
describeErrorwithMatch.type<NotFound | Forbidden>(),Match.tag, andMatch.exhaustive. It returns"NotFound"or"Forbidden for <userId>". - Write
report(orderId, requester). It maps a success to"order <id>: <items> items". It maps each error, withEffect.catch, to"order <id>: <describeError(e)>".
Run it as the user u-1 for the orders o-1, o-2, and o-9. The output must be:
order o-1: 3 items
order o-2: Forbidden for u-1
order o-9: NotFoundimport { Brand, Effect, Match, Schema } from "effect"
// TODO: UserId and OrderId as nominal brands over string
type UserId = string
type OrderId = string
class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { orderId: Schema.String }) {}
class Forbidden extends Schema.TaggedError<Forbidden>()("Forbidden", { userId: Schema.String }) {}
interface Order { readonly owner: UserId; readonly items: number }
const orders: Record<string, Order> = {
"o-1": { owner: "u-1", items: 3 },
"o-2": { owner: "u-2", items: 1 }
}
// TODO: getOrder with Effect.fn: NotFound when missing, Forbidden when owned by someone else
const getOrder = (orderId: OrderId, requester: UserId): Effect.Effect<Order, NotFound | Forbidden> =>
Effect.fail(new NotFound({ orderId }))
// TODO: describeError with Match.type over the error union, exhaustive
// TODO: report(orderId, requester) maps success and errors to the lines in the spec
const report = (orderId: OrderId, requester: UserId): Effect.Effect<string> => Effect.succeed("")
const program = Effect.gen(function* () {
const me = "u-1"
for (const id of ["o-1", "o-2", "o-9"]) {
console.log(yield* report(id, me))
}
})
Effect.runSync(program)
2. A document workflow with a tagged enum #
Model a document as a Data.TaggedEnum with 3 states: Draft { title }, InReview { title, reviewer }, and Published { title }. The commands are "submit", "approve", and "reject".
Write transition(doc, command). It returns Effect<Doc, InvalidTransition>. Build it with 1 exhaustive $match over the current state:
Draft+submitbecomesInReviewwith the reviewer"lin"InReview+approvebecomesPublished.InReview+rejectbecomesDraft- every other combination fails with
InvalidTransition({ from: doc._tag, command })
Start with a Draft with the title "Effect style guide". Apply submit, reject, submit, approve, submit in this order. Use Effect.result on each transition. On success, print <command>: <from> -> <to> and move to the new state. On failure, print <command>: invalid from <from> and keep the state. At the end, print final: <state>. The output must be:
submit: Draft -> InReview
reject: InReview -> Draft
submit: Draft -> InReview
approve: InReview -> Published
submit: invalid from Published
final: Publishedimport { Data, Effect, Result, Schema } from "effect"
// TODO: Doc as a Data.TaggedEnum with Draft { title }, InReview { title, reviewer }, Published { title }
type Command = "submit" | "approve" | "reject"
class InvalidTransition extends Schema.TaggedError<InvalidTransition>()("InvalidTransition", {
from: Schema.String,
command: Schema.String
}) {}
// TODO: transition(doc, command) using $match, failing with InvalidTransition
const program = Effect.gen(function* () {
// TODO: start as Draft "Effect style guide", apply the commands in the spec, print each line
for (const command of ["submit", "reject", "submit", "approve", "submit"] as const) {
console.log(command + ": TODO")
}
console.log("final: TODO")
})
Effect.runSync(program)
Recall #
Answer in your head first, then reveal. Come back to these tomorrow.
You have 3 steps. The third step needs values from the first and the second. `gen` or `pipe`? #
Effect.gen. A pipe passes only the previous result forward. To keep an earlier value in scope, you need nested flatMap closures. gen gives every step a name with const x = yield* ....
What does it mean that `Effect.map` is a dual API? #
It has 2 overloads. Data-first is Effect.map(effect, f). Data-last is Effect.map(f), which returns a function for .pipe. In the docs, the overload whose first parameter is self is data-first. Both produce the same effect.
What is the type of `Effect.fn("findUser")(function* (id: number) { ...; return "Ada" })`? #
A function (id: number) => Effect<string, never, never>. This is the same type as an arrow function that wraps Effect.gen. If the body does return yield* new NotFound(...), the error type becomes NotFound. The name changes only traces and spans, not the type.
Which function do you use so that `UserId` and `OrderId` cannot be exchanged, with no run-time cost? #
Brand.nominal<UserId>() over type UserId = Brand.Branded<string, "UserId">. It adds a compile-time tag and returns its input without a change. Use Brand.make(predicate) when the constructor must also validate.
Why use `Match.type<T>()` with `Match.exhaustive` instead of a `switch`? #
When a person adds a case to T, Match.exhaustive does not compile until you process the case. The type of unprocessed cases is no longer never. A switch compiles and returns undefined. Match is also an expression, and it supports predicates and partial shapes.
What does `Data.taggedEnum<T>()` give you? #
A constructor for each case (Pending({ amount })), a $is("Case") type guard that narrows the type, and an exhaustive $match. You can call $match data-first ($match(value, cases)) or data-last ($match(cases)).
Where must `Effect.runSync` or `Effect.runPromise` appear in an application? #
One time, at the edge: in main, in a test, or at a framework boundary such as a request handler. Inside business logic, use flatMap, forEach, or yield*. A runSync inside a map throws on async work. It also hides the inner effect from layers, retries, and timeouts.