Option, Result & Data
Absence and settled outcomes as first-class values — the Option and Result APIs, Result's success-first parameter order (flipped from v3's Either), yield* semantics for both, structural equality by default in v4, the Data module, and branded opaque ids.
Two data types do most of the “make invalid states unrepresentable” work in
Effect codebases: Option<A> for maybe absent, Result<A, E> for already
settled, either way. Both are plain values you transform synchronously; both
are also yieldable inside Effect.gen, which is where they meet the error
channel. This chapter tours both APIs, flags the breaking change v3 developers
trip over (Result is success-first), then covers the equality overhaul that
silently changed every Map, Set, and test assertion in your codebase.
Option<A>
Section titled “Option<A>”type Option<A> = Some<A> | None<A>
interface Some<A> { readonly _tag: "Some"; readonly value: A }interface None<A> { readonly _tag: "None" }The null-check problem, solved with a type. Construction, narrowing,
folding:
import { Option } from "effect"
const found = Option.some(42)const missed = Option.none<number>()
// narrowif (Option.isSome(found)) { found.value // number}
// foldconst label = Option.match(found, { onNone: () => "nothing", onSome: (n) => `got ${n}`})
// fallbacksOption.getOrElse(missed, () => 0) // 0Option.getOrNull(missed) // null — for JS-facing boundariesOption.getOrUndefined(missed) // undefined
// transformconst doubled = Option.map(found, (n) => n * 2) // Option.some(84)
// filterMap: keep-and-transform in one passconst evens = Option.filterMap(Option.some(7), (n) => (n % 2 === 0 ? Option.some(n) : Option.none()))// Option.none — predicate failure becomes absencefromNullishOr converts nullable values at the boundary:
import { Option } from "effect"
declare const input: string | nullconst maybe: Option.Option<string> = Option.fromNullishOr(input)Two more members worth knowing:
Option.gen— generator syntax for synchronous Option pipelines (short- circuits on the firstNone), the same trickResult.genuses below.Option.liftPredicate(predicate, orFailWith)— turn a plain value intoSome/Nonebased on a predicate; the data-validation flavour offilterMap.
Option is yieldable — absence fails
Section titled “Option is yieldable — absence fails”Inside Effect.gen, yielding an Option unwraps a Some; a None fails
the effect with NoSuchElementError:
import { Cause, Effect } from "effect"
declare const lookupUser: (id: string) => Effect.Effect<Option.Option<{ name: string }>>
const program = Effect.gen(function* () { const user = yield* lookupUser("u_1") // user: { name: string } return user.name})
await Effect.runPromiseExit(program)// Exit.fail(Cause.NoSuchElementError) if the lookup returned NoneThat’s an opinionated default: absence became an error. When you want it back as a value instead, one combinator round-trips it:
const tolerant = lookupUser("u_1").pipe(Effect.catchNoSuchElement)// Effect<Option<{ name: string }>> — NoSuchElementError → None, others untouchedEffect.catchNoSuchElement removes exactly NoSuchElementError from the
error channel and re-wraps the outcome as Option. It composes cleanly when
several yielded lookups might be empty but only some are exceptional.
Related conversions, all verified parts of the module surface:
Effect.fromOption(option, onError?) lifts an Option into the error channel;
Effect.transposeOption turns Option<Effect> into Effect<Option>; and
Option.gen gives you the same generator syntax for synchronous pipelines of
Options.
Result<A, E> — success first!
Section titled “Result<A, E> — success first!”Here is the change that breaks every v3 instinct:
// v3: Either<E, A> ← error FIRST (Scala/Rust heritage)// v4: Result<A, E> ← SUCCESS FIRST (matches Effect<A, E>)The flip makes Result’s parameter order agree with Effect’s: the happy
path leads. Where v3 muscle memory writes Either<UserNotFound, string> for
“a string or a not-found”, v4 writes Result<string, UserNotFound>.
Concretely: first type parameter = success, second = failure.
import { Result } from "effect"
type Parsed = Result.Result<number, string>
const ok: Parsed = Result.succeed(42)const bad: Parsed = Result.fail("not a number")
// accessors after narrowing — note the property names match the variantsif (Result.isSuccess(ok)) { ok.success // number}if (Result.isFailure(bad)) { bad.failure // string}
// foldResult.match(ok, { onSuccess: (n) => `value ${n}`, onFailure: (e) => `why: ${e}`})
// mapping mirrors Effect's namingResult.map(ok, (n) => n + 1) // Success(43)Result.mapError(bad, (e) => new Error(e)) // Failure(Error)Result.mapBoth(bad, { onFailure: (e) => new Error(e), onSuccess: (n) => n * 2})Result.flatMap(Result.succeed(2), (n) => n > 0 ? Result.succeed(n * 10) : Result.fail("non-positive")) // Success(20)
// recoveryResult.getOrElse(bad, () => -1) // -1Result.orElse(bad, () => Result.succeed(0)) // Success(0)
// swap channelsResult.flip(ok) // Failure(42)Like Option, Result has a generator DSL — evaluated eagerly and
synchronously, unlike Effect.gen:
import { Result } from "effect"
const total = Result.gen(function* () { const a = yield* Result.succeed(1) const b = yield* parseIntSafe("2") return a + b})// short-circuits with the first Failure encounteredFor independent Results that should all hold, Result.all collects tuples and
records, short-circuiting on the first failure:
import { Result } from "effect"
declare const a: Result.Result<number, string>declare const b: Result.Result<number, string>declare const bad: Result.Result<number, string>
Result.all([a, b]) // Result<[number, number], string>Result.all({ width: a, height: bad }) // Result<{ width: number; height: number }, string>There’s no concurrency dimension here — these are already-settled values — so
all is pure structure reshaping, unlike its Effect namesake.
Result is yieldable too
Section titled “Result is yieldable too”Yielding a Result inside Effect.gen feeds the success value through; a
failure fails the effect with its E:
import { Effect, Result, Schema } from "effect"
class InvalidInput extends Schema.TaggedError<InvalidInput>()("InvalidInput", { detail: Schema.String}) {}
const parseQty = (raw: string): Result.Result<number, InvalidInput> => /^\d+$/.test(raw) ? Result.succeed(Number(raw)) : Result.fail(new InvalidInput({ detail: raw }))
const order = Effect.gen(function* () { const qty = yield* parseQty("12") // qty: number; InvalidInput joins E return qty * 2})// Effect<number, InvalidInput>This is the bridge between pure parsing/validation layers (plain functions,
Result) and effectful orchestration (Effect). Keep business rules as
Result-returning functions — trivially unit-testable, no runtime involved —
and lift them into effects at the edge.
Interop helpers
Section titled “Interop helpers”| From Effect world to data | Code |
|---|---|
Settled effect → Result |
yield* Effect.result(eff) / eff.pipe(Effect.result) |
Settled effect → Option |
Effect.option(eff) |
Result back into channel |
Effect.fromResult(result) |
| Full envelope incl. defects | Effect.exit(eff) (chapter 06) |
Effect.result never fails: typed errors become Failure(e); defects stay
defects. That distinction is why result exists separately from exit.
Typed absence vs the error channel
Section titled “Typed absence vs the error channel”A decision table, because this comes up weekly:
| Situation | Reach for |
|---|---|
| Lookup may legitimately miss; caller decides what missing means | Option<A> |
| Operation may fail; failure carries why; caller must handle | Effect<A, E> |
| Pure computation may fail; testability matters; no I/O yet | Result<A, E> |
| Absence would be a broken invariant | yield raw / NoSuchElementError |
You’re about to reach for E = Option<X> or boolean returns |
don’t |
The smell to avoid: encoding absence in the error channel
(Effect<A, NotFound | null>-style unions) or presence in the success
channel (Effect<A | null>). Each forces consumers into defensive checks the
types were supposed to eliminate.
Structural equality is the default now
Section titled “Structural equality is the default now”v3 compared plain objects by reference unless you wrapped them in Data. v4
inverts the default: Equal.equals compares plain objects, arrays, Maps,
Sets, Dates, and RegExps structurally — and everything built on it (Cause,
Exit, Option, Result, Data instances, Filter.equals, HashMap keys…)
follows.
import { Equal } from "effect"
Equal.equals({ a: 1 }, { a: 1 }) // true — was false in v3!Equal.equals([1, [2, 3]], [1, [2, 3]]) // trueEqual.equals(NaN, NaN) // true — NaN === NaN hereEqual.equals(new Date("2026-01-01"), new Date("2026-01-01")) // true (by timestamp)
Equal.equals( new Map([["a", 1], ["b", 2]]), new Map([["b", 2], ["a", 1]])) // true — order-independent
Equal.equals(/abc/g, /abc/g) // true (by source+flags)Consequences you will actually hit
Section titled “Consequences you will actually hit”1. Map/Set keys use structural hashing in Effect collections.
import { Equal, HashSet } from "effect"
HashSet.make({ id: 1 }).has({ id: 1 }) // true — was false-ish in v3 (needed Data)Native new Map()/new Set() still compare by reference — structural
equality applies to Effect’s own structures and Equal.equals calls, not to
V8 internals. If you pass plain objects as keys to native Maps across a
boundary where someone else might construct equal-but-distinct objects, prefer
HashMap/HashSet.
2. Tests assert by value. Expectations like
expect(runProgram()).toEqual({...}) align with what Equal.equals reports,
and Effect-based assertions can compare whole Exits/Causes directly:
import { Equal, Exit } from "effect"import { expect } from "vitest"
declare const runProgram: () => Exit.Exit<number, string>
expect(Equal.equals(runProgram(), Exit.succeed(42))).toBe(true)That single line replaces a page of v3 assertion helpers — exits with equal
causes (defects included) compare equal, so failure-shape regression tests are
one Equal.equals away.
3. Don’t mutate after comparing. Comparison results are cached per object pair internally. Mutating an object after its first comparison yields stale results — treat compared objects as immutable, which you should be doing anyway.
Opting out
Section titled “Opting out”Two escape hatches, one safe, one fast:
import { Equal } from "effect"
const a = { x: 1 }const b = { x: 1 }
// proxy wrapper: original untouched, reads through normallyconst ref1 = Equal.byReference(a)Equal.equals(ref1, b) // falseref1.x // 1
// marks the object itself (irreversible, zero allocation)const obj = { y: 2 }const ref2 = Equal.byReferenceUnsafe(obj)ref2 === obj // trueEqual.equals(obj, { y: 2 }) // false foreverUse these for identity-meaningful objects: cache entries keyed by handle,
mutable builders, anything whose reference is the point. Note
byReference(x) !== byReference(x) — each call wraps anew.
The Data module — when classes still help
Section titled “The Data module — when classes still help”Given free structural equality, most of v3’s reasons for reaching for Data
evaporated. What remains is real but narrower:
import { Data } from "effect"
// 1. Pipeable value classes with methodsclass Money extends Data.Class<{ readonly cents: number }> { add(that: Money): Money { return new Money({ cents: this.cents + that.cents }) } toString(): string { return `$${(this.cents / 100).toFixed(2)}` }}Equal.equals(new Money({ cents: 500 }), new Money({ cents: 500 })) // true
// 2. Tagged single variantsclass Started extends Data.TaggedClass("Started")<{ readonly at: number }> {}new Started({ at: 1 })._tag // "Started"
// 3. Yieldable errors without Schema (chapter 05 covers Schema.TaggedError)class Denied extends Data.TaggedError("Denied")<{ who: string }> {}
// 4. Tagged enums: union + constructors + matchers in one declarationtype Conn = | Data.TaggedEnum<{ Open: { readonly host: string }; Closed: {} }>const Conn = Data.taggedEnum<Conn>()
const c = Conn.Open({ host: "db.local" })c._tag // "Open"Conn.$is("Closed")(c) // false
// $match: total fold over the union, curried or directconst label = Conn.$match(c, { Open: ({ host }) => `connected to ${host}`, Closed: () => "disconnected"})What Data.Class still buys you over a bare object literal:
- a real class: methods,
extends, nominal identity in stack traces Pipeable:.pipe(...)chains like effects- structural
Equal/Hashimplemented consistently (which literals get for free anyway in v4 — so this is about ergonomics, not correctness) Data.TaggedError: the yieldable-error behaviour
When plain literals suffice — payloads crossing JSON boundaries, ad-hoc
tuples, most function returns — skip Data. The remaining sweet spots are
domain objects carrying behaviour and discriminated unions constructed in many
places.
Opaque ids: Brand & Newtype
Section titled “Opaque ids: Brand & Newtype”Structural equality cuts both ways: { id: "u_1" } equals any other object
shaped like it, including ones that aren’t users. For domain identifiers you
want nominal typing — two different id types that both wrap strings should
not mix.
The lightweight tool is branding — intersecting with a phantom marker:
import { Brand } from "effect"
export type UserId = string & Brand.Brand<"UserId">export type OrderId = string & Brand.Brand<"OrderId">
declare function fetchUser(id: UserId): void
const raw = "u_1"fetchUser(raw) // ✗ compile error: string is not UserIdfetchUser(raw as UserId) // ✓ explicit cast at the trust boundaryBrand.Brand<"Key"> is just a phantom property in a unique symbol — erased at
runtime, so branded strings remain plain strings with zero overhead. Structural
equality still works within a brand (two UserIds with the same text compare
equal).
For constructors, Brand.nominal<T>() produces a callable wrapper with no
runtime validation (.option/.result/.is variants included); validated
construction is Brand.check/Brand.make territory:
import { Brand } from "effect"
const mkUserId = Brand.nominal<UserId>()
const u1 = mkUserId("u_1" as UserId) // no runtime check, just the cast formalisedIn practice, most codebases brand via Schema — Schema.brand adds the
phantom during decoding, so ids are minted validated at the boundary and flow
through the app nominally typed. Chapter 20 covers the schema side; the rule
to remember: brand at decode time, consume everywhere.
(The older Newtype module still exists for class-free opaque wrappers, but
brands compose better with Schema and need no symbols at usage sites.)
Cheat sheet
Section titled “Cheat sheet”| Task | Code |
|---|---|
| Maybe-absent value | Option.some(v) / Option.none() |
| Unwrap Option in gen | const v = yield* opt (None → NoSuchElementError) |
| Absence back to value | Effect.catchNoSuchElement |
| Settled outcome | Result.succeed(a) / Result.fail(e) |
| Remember the order | Result<A, E> — success first |
| Unwrap Result in gen | const v = yield* res (failure → E in channel) |
| Effect → data | Effect.result / Effect.option / Effect.exit |
| Compare structurally | Equal.equals(a, b) |
| Reference semantics | Equal.byReference(obj) |
| Value class | Data.Class<{...}> + methods |
| Tagged union + ctors | Data.TaggedEnum + Data.taggedEnum |
| Opaque id | string & Brand.Brand<"UserId"> |