Skip to content

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.

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:

src/option-basics.ts
import { Option } from "effect"
const found = Option.some(42)
const missed = Option.none<number>()
// narrow
if (Option.isSome(found)) {
found.value // number
}
// fold
const label = Option.match(found, {
onNone: () => "nothing",
onSome: (n) => `got ${n}`
})
// fallbacks
Option.getOrElse(missed, () => 0) // 0
Option.getOrNull(missed) // null — for JS-facing boundaries
Option.getOrUndefined(missed) // undefined
// transform
const doubled = Option.map(found, (n) => n * 2) // Option.some(84)
// filterMap: keep-and-transform in one pass
const evens = Option.filterMap(Option.some(7), (n) => (n % 2 === 0 ? Option.some(n) : Option.none()))
// Option.none — predicate failure becomes absence

fromNullishOr converts nullable values at the boundary:

import { Option } from "effect"
declare const input: string | null
const maybe: Option.Option<string> = Option.fromNullishOr(input)

Two more members worth knowing:

  • Option.gen — generator syntax for synchronous Option pipelines (short- circuits on the first None), the same trick Result.gen uses below.
  • Option.liftPredicate(predicate, orFailWith) — turn a plain value into Some/None based on a predicate; the data-validation flavour of filterMap.

Inside Effect.gen, yielding an Option unwraps a Some; a None fails the effect with NoSuchElementError:

src/option-gen.ts
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 None

That’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 untouched

Effect.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.

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.

src/result-basics.ts
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 variants
if (Result.isSuccess(ok)) {
ok.success // number
}
if (Result.isFailure(bad)) {
bad.failure // string
}
// fold
Result.match(ok, {
onSuccess: (n) => `value ${n}`,
onFailure: (e) => `why: ${e}`
})
// mapping mirrors Effect's naming
Result.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)
// recovery
Result.getOrElse(bad, () => -1) // -1
Result.orElse(bad, () => Result.succeed(0)) // Success(0)
// swap channels
Result.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 encountered

For 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.

Yielding a Result inside Effect.gen feeds the success value through; a failure fails the effect with its E:

src/result-gen.ts
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.

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.

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.

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.

src/equality.ts
import { Equal } from "effect"
Equal.equals({ a: 1 }, { a: 1 }) // true — was false in v3!
Equal.equals([1, [2, 3]], [1, [2, 3]]) // true
Equal.equals(NaN, NaN) // true — NaN === NaN here
Equal.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)

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.

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 normally
const ref1 = Equal.byReference(a)
Equal.equals(ref1, b) // false
ref1.x // 1
// marks the object itself (irreversible, zero allocation)
const obj = { y: 2 }
const ref2 = Equal.byReferenceUnsafe(obj)
ref2 === obj // true
Equal.equals(obj, { y: 2 }) // false forever

Use 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:

src/data.ts
import { Data } from "effect"
// 1. Pipeable value classes with methods
class 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 variants
class 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 declaration
type 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 direct
const 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/Hash implemented 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.

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:

src/ids.ts
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 UserId
fetchUser(raw as UserId) // ✓ explicit cast at the trust boundary

Brand.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 formalised

In 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.)

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">