Skip to content

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"

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 } }
// Read
console.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) => S
const s3 = _age.modify((n) => n + 1)(s1)
console.log(s3)
// => { user: { name: "Alice", age: 31 } }
// Structural sharing: only nodes on the path are cloned
console.log(s2.user !== s1.user)
// => true

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:

OpticReadWriteUse when
Isoalways succeedsalways succeedslossless two-way conversion (Celsius ↔ Fahrenheit, Record ↔ entries)
Lensalways succeedsneeds original Sa part that always exists (a required struct field)
Prismcan failbuilds S from A alonea part that may be absent (a union variant, a validated subset)
Optionalcan failcan failthe most general case (a record key that may not exist)
Traversalzero-or-morezero-or-moremany 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 = Lens
Optic.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")

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))
// => 212
console.log(fahrenheit.set(32))
// => 0

Lens<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 })))
// => true
console.log(Result.isFailure(_circle.getResult({ _tag: "Rect", width: 10 })))
// => true
// set builds a fresh S from A alone
console.log(_circle.set({ _tag: "Circle", radius: 9 }))
// => { _tag: "Circle", radius: 9 }

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> or Result.Failure<string>.
  • replaceResult(a, s)Result.Success<S> or Result.Failure<string>.
  • replace(a, s) / modify(f) return the original s on 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" })))
// => true
console.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 explicit
console.log(Result.isFailure(_home.replaceResult("/new", { PATH: "/bin" })))
// => true

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] }

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 }))
// => 100
console.log(meters.set(42))
// => { value: 42 }

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(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")))
// => true
console.log(numeric.set(42))
// => "42"

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 })))
// => true
console.log(Result.isFailure(atKey("x").getResult({ y: 2 })))
// => true

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)))
// => true
console.log(Result.isFailure(posInt.getResult(-1)))
// => true

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

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

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 } }))
// => 42

.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)))
// => true
console.log(Result.isFailure(_pos.getResult(-1)))
// => true

.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 })))
// => true
console.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 })))
// => true
console.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() 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)))
// => true
console.log(Result.isFailure(_defined.getResult(undefined)))
// => true

.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 elements
const _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(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] }

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 }))
// => 42

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 values
const _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))))
// => true
console.log(Result.isFailure(_some.getResult(Option.none())))
// => true
console.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())))
// => true
console.log(Result.isFailure(_none.getResult(Option.some(1))))
// => true

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))))
// => true
console.log(Result.isFailure(_ok.getResult(Result.fail("err"))))
// => true

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"))))
// => true
console.log(Result.isFailure(_err.getResult(Result.succeed(42))))
// => true

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] }))
// => []

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 likes
const _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 untouched
console.log(state.user.posts[1].likes)
// => 1

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

  • Option — for the some / none prisms.
  • Result — the return type of getResult / replaceResult, and the target of the success / failure prisms.
  • Schema filters — the checks used by .check() and fromChecks().