Optics
An optic is a first-class, reusable reference to a piece of data inside a
larger structure. Instead of writing manual spread-based update code every time
you need to change a deeply nested field, you build an optic once and use it to
get, replace, or modify that field — immutably, with structural sharing.
The whole module lives in core effect:
import { Optic } from "effect"Quickstart
Section titled “Quickstart”Start a chain with Optic.id<S>(), drill in with .key(...), then read and
update nested immutable state:
import { Optic } from "effect"
type State = { user: { name: string; age: number } }
const _age = Optic.id<State>().key("user").key("age")
const s1: State = { user: { name: "Alice", age: 30 } }
// Readconsole.log(_age.get(s1))// => 30
// Update immutably (returns a new object; s1 is untouched)const s2 = _age.replace(31, s1)console.log(s2)// => { user: { name: "Alice", age: 31 } }
// Modify with a function — note modify returns a function (s: S) => Sconst s3 = _age.modify((n) => n + 1)(s1)console.log(s3)// => { user: { name: "Alice", age: 31 } }
// Structural sharing: only nodes on the path are clonedconsole.log(s2.user !== s1.user)// => trueThe mental model
Section titled “The mental model”An optic packages up “how to look at A given an S” together with “how to put
a new A back into an S”. There are five flavors, ordered by how much they
guarantee:
| Optic | Read | Write | Use when |
|---|---|---|---|
| Iso | always succeeds | always succeeds | lossless two-way conversion (Celsius ↔ Fahrenheit, Record ↔ entries) |
| Lens | always succeeds | needs original S | a part that always exists (a required struct field) |
| Prism | can fail | builds S from A alone | a part that may be absent (a union variant, a validated subset) |
| Optional | can fail | can fail | the most general case (a record key that may not exist) |
| Traversal | zero-or-more | zero-or-more | many elements at once (every item in an array) |
The strength hierarchy is Iso > Lens | Prism > Optional, and a Traversal
is technically an Optional<S, ReadonlyArray<A>>. Iso extends both Lens and
Prism; all four extend Optional.
When you compose two optics, the result is the weaker of the two:
import { Optic } from "effect"
// Lens . Lens = LensOptic.id<{ a: { b: number } }>().key("a").key("b")
// Lens . Prism = Optional (key is a Lens; .tag is a Prism)Optic.id<{ shape: { _tag: "Circle"; r: number } | { _tag: "Sq"; s: number } }>() .key("shape") .tag("Circle")The optic types
Section titled “The optic types”Iso<S, A> is a lossless, reversible conversion between S and A: get(s)
and set(a) both always succeed and round-trip (get(set(a)) === a). It is the
strongest optic and composes with anything.
import { Optic } from "effect"
const fahrenheit = Optic.makeIso<number, number>( (c) => (c * 9) / 5 + 32, (f) => ((f - 32) * 5) / 9)
console.log(fahrenheit.get(100))// => 212console.log(fahrenheit.set(32))// => 0Lens<S, A> focuses on exactly one part of S. get(s) always succeeds;
replace(a, s) needs the original S to rebuild the updated whole. It exposes
a get method on top of the Optional interface.
import { Optic } from "effect"
type Person = { readonly name: string; readonly age: number }
const _name = Optic.id<Person>().key("name")
console.log(_name.get({ name: "Alice", age: 30 }))// => "Alice"console.log(_name.replace("Bob", { name: "Alice", age: 30 }))// => { name: "Bob", age: 30 }Prism<S, A> focuses on a part that may not be present — a union variant or a
validated subset. getResult(s) can fail; set(a) builds a new S from A
alone (no original S needed). See Result for the return
type.
import { Optic, Result } from "effect"
type Shape = | { readonly _tag: "Circle"; readonly radius: number } | { readonly _tag: "Rect"; readonly width: number }
const _circle = Optic.id<Shape>().tag("Circle")
console.log(Result.isSuccess(_circle.getResult({ _tag: "Circle", radius: 5 })))// => trueconsole.log(Result.isFailure(_circle.getResult({ _tag: "Rect", width: 10 })))// => true
// set builds a fresh S from A aloneconsole.log(_circle.set({ _tag: "Circle", radius: 9 }))// => { _tag: "Circle", radius: 9 }Optional
Section titled “Optional”Optional<S, A> is the most general optic: both reading and writing can
fail. It is the base interface every other optic extends, and the type you get
when composing across kinds (e.g. Lens . Prism).
getResult(s)→Result.Success<A>orResult.Failure<string>.replaceResult(a, s)→Result.Success<S>orResult.Failure<string>.replace(a, s)/modify(f)return the originalson failure (never throw).
import { Optic, Result } from "effect"
type Env = { [key: string]: string }const _home = Optic.id<Env>().at("HOME")
console.log(Result.isSuccess(_home.getResult({ HOME: "/root" })))// => trueconsole.log(Result.isFailure(_home.getResult({ PATH: "/bin" })))// => true
// replace returns the original on failure...console.log(_home.replace("/new", { PATH: "/bin" }))// => { PATH: "/bin" }
// ...replaceResult makes the failure explicitconsole.log(Result.isFailure(_home.replaceResult("/new", { PATH: "/bin" })))// => trueTraversal
Section titled “Traversal”Traversal<S, A> focuses on zero or more elements of an array-like
structure. It is Optional<S, ReadonlyArray<A>> under the hood. Build one with
.forEach(...), update every element with .modifyAll(f), and extract the
matches with getAll.
import { Optic, Schema } from "effect"
type S = { readonly items: ReadonlyArray<number> }
const _positive = Optic.id<S>() .key("items") .forEach((n) => n.check(Schema.isGreaterThan(0)))
console.log(_positive.modifyAll((n) => n * 2)({ items: [1, -2, 3] }))// => { items: [2, -2, 6] }Constructors
Section titled “Constructors”makeIso
Section titled “makeIso”makeIso(get, set) builds an Iso from two pure, lossless conversion
functions.
import { Optic } from "effect"
type Meters = { readonly value: number }const meters = Optic.makeIso<Meters, number>( (m) => m.value, (n) => ({ value: n }))
console.log(meters.get({ value: 100 }))// => 100console.log(meters.set(42))// => { value: 42 }makeLens
Section titled “makeLens”makeLens(get, replace) builds a Lens from a getter and a replacer.
replace(a, s) should return a structurally new S.
import { Optic } from "effect"
const _first = Optic.makeLens<readonly [string, number], string>( (pair) => pair[0], (s, pair) => [s, pair[1]])
console.log(_first.get(["hello", 42]))// => "hello"console.log(_first.replace("world", ["hello", 42]))// => ["world", 42]makePrism
Section titled “makePrism”makePrism(getResult, set) builds a Prism from a fallible getter
(return Result.fail(message) on mismatch) and an infallible setter.
import { Optic, Result } from "effect"
const numeric = Optic.makePrism<string, number>( (s) => { const n = Number(s) return Number.isNaN(n) ? Result.fail("not a number") : Result.succeed(n) }, String)
console.log(Result.isSuccess(numeric.getResult("42")))// => trueconsole.log(numeric.set(42))// => "42"makeOptional
Section titled “makeOptional”makeOptional(getResult, set) builds the most general Optional:
both the getter and the setter return a Result.
import { Optic, Result } from "effect"
const atKey = (key: string) => Optic.makeOptional<Record<string, number>, number>( (s) => Object.hasOwn(s, key) ? Result.succeed(s[key]) : Result.fail(`Key "${key}" not found`), (a, s) => Object.hasOwn(s, key) ? Result.succeed({ ...s, [key]: a }) : Result.fail(`Key "${key}" not found`) )
console.log(Result.isSuccess(atKey("x").getResult({ x: 1 })))// => trueconsole.log(Result.isFailure(atKey("x").getResult({ y: 2 })))// => truefromChecks
Section titled “fromChecks”fromChecks(...checks) builds a Prism<T, T> from one or more
Schema checks. getResult runs every check and fails with a
combined message; set is the identity.
import { Optic, Result, Schema } from "effect"
const posInt = Optic.fromChecks<number>( Schema.isGreaterThan(0), Schema.isInt())
console.log(Result.isSuccess(posInt.getResult(3)))// => trueconsole.log(Result.isFailure(posInt.getResult(-1)))// => trueCombinators
Section titled “Combinators”These are methods on the optic instances. The kind of optic returned follows the
strength hierarchy (a method on a Lens returns a Lens; the same method on an
Optional returns an Optional, etc.).
compose
Section titled “compose”a.compose(b) chains two optics, producing the weaker of the two kinds
(Iso + Iso = Iso, Lens + Prism = Optional, …).
import { Optic, Option } from "effect"
type State = { value: Option.Option<number> }
// Lens (.key) composed with Prism (some) => Optional<State, number>const _inner = Optic.id<State>().key("value").compose(Optic.some())
console.log(_inner.replace(7, { value: Option.some(1) }))// => { value: { _tag: "Some", value: 7 } }modify
Section titled “modify”optic.modify(f) returns a function (s: S) => S that applies f to the
focused value. On focus failure the original s is returned unchanged.
import { Optic } from "effect"
type S = { readonly a: { readonly b: number } }const _b = Optic.id<S>().key("a").key("b")
console.log(_b.modify((n) => n + 1)({ a: { b: 1 } }))// => { a: { b: 2 } }.key("name") focuses on a struct/tuple property. On a Lens it returns a
Lens; on an Optional, an Optional. Does not work on union types (compile
error).
import { Optic } from "effect"
type S = { readonly a: { readonly b: number } }const _b = Optic.id<S>().key("a").key("b")
console.log(_b.get({ a: { b: 42 } }))// => 42optionalKey
Section titled “optionalKey”.optionalKey("name") focuses a key where setting undefined removes the
key (or splices the element out of an array/tuple). The focus type becomes
A[Key] | undefined. No union types.
import { Optic } from "effect"
type S = { readonly a?: number }const _a = Optic.id<S>().optionalKey("a")
console.log(_a.replace(undefined, { a: 1 }))// => {}console.log(_a.replace(2, {}))// => { a: 2 }.check(...checks) adds one or more Schema checks to the
chain. getResult fails when any check fails; set passes through unchanged. On
a Prism returns a Prism; on an Optional, an Optional.
import { Optic, Result, Schema } from "effect"
const _pos = Optic.id<number>().check(Schema.isGreaterThan(0))
console.log(Result.isSuccess(_pos.getResult(5)))// => trueconsole.log(Result.isFailure(_pos.getResult(-1)))// => truerefine
Section titled “refine”.refine(guard, annotations?) narrows the focus to a subtype using a type
guard. Pass annotations to customize the failure message.
import { Optic, Result } from "effect"
type B = { readonly _tag: "b"; readonly b: number }type S = { readonly _tag: "a"; readonly a: string } | B
const _b = Optic.id<S>().refine( (s: S): s is B => s._tag === "b", { expected: `"b" tag` })
console.log(Result.isSuccess(_b.getResult({ _tag: "b", b: 1 })))// => true.tag("Variant") narrows a tagged union to the variant with the given _tag. A
shorthand for .refine(s => s._tag === tag).
import { Optic, Result } from "effect"
type Shape = | { readonly _tag: "Circle"; readonly radius: number } | { readonly _tag: "Rect"; readonly width: number }
const _radius = Optic.id<Shape>().tag("Circle").key("radius")
console.log(Result.isSuccess(_radius.getResult({ _tag: "Circle", radius: 5 })))// => trueconsole.log(Result.isFailure(_radius.getResult({ _tag: "Rect", width: 10 })))// => true.at("key") focuses a key only if it exists (Object.hasOwn). Unlike .key(),
both getResult and replaceResult fail when the key is absent — ideal for
records and arrays. Always returns an Optional.
import { Optic, Result } from "effect"
type Env = { [key: string]: number }const _x = Optic.id<Env>().at("x")
console.log(Result.isSuccess(_x.getResult({ x: 1 })))// => trueconsole.log(Result.isFailure(_x.getResult({ y: 2 })))// => true.pick(["a", "c"]) focuses on a subset of struct keys. On a Lens returns a
Lens; on an Optional, an Optional. No union types.
import { Optic } from "effect"
type S = { readonly a: string; readonly b: number; readonly c: boolean }const _ac = Optic.id<S>().pick(["a", "c"])
console.log(_ac.get({ a: "hi", b: 1, c: true }))// => { a: "hi", c: true }.omit(["b"]) focuses on all keys except the given ones — the inverse of
.pick(). No union types.
import { Optic } from "effect"
type S = { readonly a: string; readonly b: number; readonly c: boolean }const _ac = Optic.id<S>().omit(["b"])
console.log(_ac.get({ a: "hi", b: 1, c: true }))// => { a: "hi", c: true }notUndefined
Section titled “notUndefined”.notUndefined() filters undefined out of the focus, producing a Prism.
getResult fails when the focus is undefined.
import { Optic, Result } from "effect"
const _defined = Optic.id<number | undefined>().notUndefined()
console.log(Result.isSuccess(_defined.getResult(42)))// => trueconsole.log(Result.isFailure(_defined.getResult(undefined)))// => trueforEach
Section titled “forEach”.forEach(f) — Traversal only — focuses every element of an array-like
focus, optionally narrowing each via an element-level optic. f receives the
identity iso for the element type and returns a sub-optic. Returns a new
Traversal.
import { Optic, Schema } from "effect"
type S = { readonly items: ReadonlyArray<number> }
// only focus the positive elementsconst _positive = Optic.id<S>() .key("items") .forEach((n) => n.check(Schema.isGreaterThan(0)))
console.log(Optic.getAll(_positive)({ items: [1, -2, 3] }))// => [1, 3]modifyAll
Section titled “modifyAll”.modifyAll(f) — Traversal only — returns (s: S) => S that applies f to
every focused element (vs .modify, which operates on the whole array). On
focus failure the original s is returned.
import { Optic, Schema } from "effect"
type S = { readonly items: ReadonlyArray<number> }
const _positive = Optic.id<S>() .key("items") .forEach((n) => n.check(Schema.isGreaterThan(0)))
console.log(_positive.modifyAll((n) => n * 2)({ items: [1, -2, 3] }))// => { items: [2, -2, 6] }Built-in optics & helpers
Section titled “Built-in optics & helpers”id<S>() is the identity Iso — the entry point for almost every chain.
get(s) returns s and set(a) returns a. It is a singleton.
import { Optic } from "effect"
type S = { readonly x: number }const _x = Optic.id<S>().key("x")
console.log(_x.get({ x: 42 }))// => 42entries
Section titled “entries”entries<A>() is an Iso converting a Record<string, A> to an array of
[key, value] pairs and back (Object.entries / Object.fromEntries). Handy
for traversing record values.
import { Optic, Schema } from "effect"
// increment only the positive record valuesconst _positiveValues = Optic.entries<number>() .forEach((entry) => entry.key(1).check(Schema.isGreaterThan(0)))
console.log(_positiveValues.modifyAll((n) => n + 1)({ a: 0, b: 3, c: -1 }))// => { a: 0, b: 4, c: -1 }some<A>() is a Prism into the value inside Option.Some.
getResult fails on None; set(a) wraps a in Option.some(a). See
Option.
import { Optic, Option, Result } from "effect"
const _some = Optic.id<Option.Option<number>>().compose(Optic.some())
console.log(Result.isSuccess(_some.getResult(Option.some(42))))// => trueconsole.log(Result.isFailure(_some.getResult(Option.none())))// => trueconsole.log(_some.set(10))// => { _tag: "Some", value: 10 }none<A>() is a Prism into Option.None, exposing undefined.
getResult succeeds with undefined on None and fails on Some;
set(undefined) produces Option.none().
import { Optic, Option, Result } from "effect"
const _none = Optic.id<Option.Option<number>>().compose(Optic.none())
console.log(Result.isSuccess(_none.getResult(Option.none())))// => trueconsole.log(Result.isFailure(_none.getResult(Option.some(1))))// => truesuccess
Section titled “success”success<A, E>() is a Prism into the success value of a
Result. getResult fails on Failure; set(a) produces
Result.succeed(a).
import { Optic, Result } from "effect"
const _ok = Optic.id<Result.Result<number, string>>().compose(Optic.success())
console.log(Result.isSuccess(_ok.getResult(Result.succeed(42))))// => trueconsole.log(Result.isFailure(_ok.getResult(Result.fail("err"))))// => truefailure
Section titled “failure”failure<A, E>() is a Prism into the failure value of a
Result. getResult fails on Success; set(e) produces
Result.fail(e).
import { Optic, Result } from "effect"
const _err = Optic.id<Result.Result<number, string>>().compose(Optic.failure())
console.log(Result.isSuccess(_err.getResult(Result.fail("oops"))))// => trueconsole.log(Result.isFailure(_err.getResult(Result.succeed(42))))// => truegetAll
Section titled “getAll”getAll(traversal) returns a function (s: S) => Array<A> that extracts all
focused elements as a fresh, mutable array (empty when the traversal cannot
focus).
import { Optic, Schema } from "effect"
type S = { readonly values: ReadonlyArray<number> }
const _pos = Optic.id<S>() .key("values") .forEach((n) => n.check(Schema.isGreaterThan(0)))
const getPositive = Optic.getAll(_pos)
console.log(getPositive({ values: [3, -1, 5] }))// => [3, 5]console.log(getPositive({ values: [-1, -2] }))// => []Real-world example
Section titled “Real-world example”Optics shine when updating immutable application state. Here a single traversal both reads and bumps the like counts of every already-liked post, deep inside nested state — no manual spreads, no mutation.
import { Optic, Schema } from "effect"
type Post = { title: string; likes: number }type AppState = { user: { posts: ReadonlyArray<Post> } }
// A reusable Traversal focused on the likes of every post that has > 0 likesconst _likedPostLikes = Optic.id<AppState>() .key("user") .key("posts") .forEach((post) => post.key("likes").check(Schema.isGreaterThan(0)))
const state: AppState = { user: { posts: [{ title: "a", likes: 0 }, { title: "b", likes: 1 }] }}
// Read: which posts are already liked, and by how much?console.log(Optic.getAll(_likedPostLikes)(state))// => [1]
// Update: add one like to every already-liked post (the unliked one is skipped)const addLike = _likedPostLikes.modifyAll((n) => n + 1)
console.log(addLike(state))// => { user: { posts: [{ title: "a", likes: 0 }, { title: "b", likes: 2 }] } }
// The original is untouchedconsole.log(state.user.posts[1].likes)// => 1Because optics are plain values, you can store _likedPostLikes on a service,
pass it around, and reuse the exact same accessor for reads, validated writes,
and bulk updates throughout your application.
See also
Section titled “See also”- Option — for the
some/noneprisms. - Result — the return type of
getResult/replaceResult, and the target of thesuccess/failureprisms. - Schema filters — the checks used by
.check()andfromChecks().