1## Introduction
2
3`effect/Optic` provides tools for building and composing functional optics.
4
5Functional optics let you focus on parts of immutable data structures to read or update them in a safe, composable way.
6
7Immutability keeps previous references valid after an update. This is useful in many domains, not only in concurrent programs.
8
9## Mental model
10
11Think of an optic as a reusable focus into a nested structure. It behaves like a pure, composable "getter + setter":
12
13- **get** a focused value (or no value if the focus does not exist)
14- **replace** the focused value
15- **modify** the focused value with a function
16
17You build small optics and compose them to reach deeper fields, optional data, or union variants.
18
19## Glossary
20
21- **Iso**: reversible focus between two types, like a lossless conversion.
22- **Lens**: focus on a field that is always present.
23- **Prism**: focus on one case of a union.
24- **Optional**: focus that might or might not exist.
25- **Traversal**: focus on zero or more items inside a collection.
26
27## Features
28
29- **Unified representation of optics.** All optics compose the same way because they share a single data type: `Optional`.
30- **Integration.** Generate `Iso` values from schemas with `Schema.toIso`.
31
32## Known Limitations
33
34The `Optic` module only works with **plain JavaScript objects** and collections (structs, records, tuples, and arrays).
35
36## Getting started
37
38These are the three operations you will use most:
39
40```ts
41import { Optic } from "effect"
42
43type S = { readonly a: number }
44const _a = Optic.id<S>().key("a")
45
46/**
47 * Get the value of the focused field
48 */
49const value = _a.get({ a: 1 })
50console.log(value) // 1
51
52/**
53 * Replace the value of the focused field
54 */
55const replaced = _a.replace(2, { a: 1 })
56console.log(replaced) // { a: 2 }
57
58/**
59 * Modify the value of the focused field
60 */
61const modified = _a.modify((n) => n + 1)({ a: 1 })
62console.log(modified) // { a: 2 }
63```
64
65### Nested data structures
66
67Suppose we have an employee object, and we want to capitalize the first character of the street name of the company address.
68
69**Example** (Uppercasing the first character of a street name)
70
71```ts
72import { Optic, String } from "effect"
73
74// Define some nested data structures
75interface Street {
76 readonly num: number
77 readonly name: string
78}
79interface Address {
80 readonly city: string
81 readonly street: Street
82}
83interface Company {
84 readonly name: string
85 readonly address: Address
86}
87interface Employee {
88 readonly name: string
89 readonly company: Company
90}
91
92// A sample employee object
93const from: Employee = {
94 name: "john",
95 company: {
96 name: "awesome inc",
97 address: {
98 city: "london",
99 street: {
100 num: 23,
101 name: "high street"
102 }
103 }
104 }
105}
106
107// Build an optic that drills down to the street name
108const _streetName = Optic.id<Employee>()
109 .key("company") // access "company"
110 .key("address") // access "address"
111 .key("street") // access "street"
112 .key("name") // access "name"
113
114// Modify the targeted value
115const capitalizeStreetName = _streetName.modify(String.capitalize)
116
117console.dir(capitalizeStreetName(from), { depth: null })
118/*
119{
120 name: 'john',
121 company: {
122 name: 'awesome inc',
123 address: {
124 city: 'london',
125 street: { num: 23, name: 'High street' }
126 }
127 }
128}
129*/
130```
131
132## Basic Usage
133
134### Accessing a key in a struct or a tuple
135
136**Example** (Reading and updating a single struct field)
137
138```ts
139import { Optic } from "effect"
140
141type S = {
142 readonly a: string
143}
144
145// Build an optic to access the "a" field
146const _a = Optic.id<S>().key("a")
147
148console.log(_a.replace("b", { a: "a" }))
149// { a: 'b' }
150```
151
152**Example** (Reading and updating the first element of a tuple)
153
154```ts
155import { Optic } from "effect"
156
157type S = readonly [string]
158
159// Build an optic to access the first element
160const _0 = Optic.id<S>().key(0)
161
162console.log(_0.replace("b", ["a"]))
163// ["b"]
164```
165
166### Choosing an optic quickly
167
168| Data shape | Use |
169| --------------------------------- | ------------------------------------ |
170| Always-present field | `key` |
171| Optional field (keep `undefined`) | `key` |
172| Optional field (drop `undefined`) | `optionalKey` |
173| Union case | `tag` |
174| Record or array index | `at` |
175| Filter and update items | `forEach` + `check` / `notUndefined` |
176
177### Accessing a group of keys in a struct
178
179#### pick
180
181**Example** (Updating multiple fields with `pick`)
182
183```ts
184import { Optic } from "effect"
185
186type S = {
187 readonly a: number
188 readonly b: number
189 readonly c: number
190}
191
192// Build an optic to access the "a" and "c" fields
193const _a = Optic.id<S>().pick(["a", "c"])
194
195console.log(_a.replace({ a: 4, c: 5 }, { a: 1, b: 2, c: 3 }))
196// { a: 4, b: 2, c: 5 }
197```
198
199#### omit
200
201**Example** (Updating all fields except a set with `omit`)
202
203```ts
204import { Optic } from "effect"
205
206type S = {
207 readonly a: number
208 readonly b: number
209 readonly c: number
210}
211
212// Build an optic to access the "a" and "c" fields
213const _a = Optic.id<S>().omit(["b"])
214
215console.log(_a.replace({ a: 4, c: 5 }, { a: 1, b: 2, c: 3 }))
216// { a: 4, b: 2, c: 5 }
217```
218
219### Accessing an optional key in a struct or a tuple
220
221There are two ways to handle an optional key in a struct or a tuple, depending on how you want to treat the `undefined` value:
222
2231. when setting `undefined`, the key is preserved
2242. when setting `undefined`, the key is removed
225
226**Example** (Preserving the key when setting `undefined`)
227
228```ts
229import { Optic } from "effect"
230
231type S = {
232 readonly a?: number | undefined
233}
234
235// Lens<S, number | undefined>
236const _a = Optic.id<S>().key("a")
237
238console.log(String(_a.getResult({ a: 1 })))
239// success(1)
240
241console.log(String(_a.getResult({})))
242// success(undefined)
243
244console.log(String(_a.getResult({ a: undefined })))
245// success(undefined)
246
247console.log(_a.replace(2, { a: 1 }))
248// { a: 2 }
249
250console.log(_a.replace(2, {}))
251// { a: 2 }
252
253console.log(_a.replace(undefined, { a: 1 }))
254// { a: undefined }
255
256console.log(_a.replace(undefined, {}))
257// { a: undefined }
258
259console.log(_a.replace(2, { a: undefined }))
260// { a: 2 }
261```
262
263**Example** (Removing the key when setting `undefined`)
264
265```ts
266import { Optic } from "effect"
267
268type S = {
269 readonly a?: number
270}
271
272// Lens<S, number | undefined>
273const _a = Optic.id<S>().optionalKey("a")
274
275console.log(String(_a.getResult({ a: 1 })))
276// success(1)
277
278console.log(String(_a.getResult({})))
279// success(undefined)
280
281console.log(_a.replace(2, { a: 1 }))
282// { a: 2 }
283
284console.log(_a.replace(2, {}))
285// { a: 2 }
286
287console.log(_a.replace(undefined, { a: 1 }))
288// {}
289
290console.log(_a.replace(undefined, {}))
291// {}
292```
293
294**Example** (Dropping a tuple element when setting `undefined`)
295
296```ts
297import { Optic } from "effect"
298
299type S = readonly [number, number?]
300
301// Build an optic to access the optional second element
302const _1 = Optic.id<S>().optionalKey(1)
303
304console.log(_1.get([1, 2]))
305// 2
306
307console.log(_1.get([1]))
308// undefined
309
310console.log(_1.replace(3, [1, 2]))
311// [1, 3]
312
313console.log(_1.replace(undefined, [1, 2]))
314// [1]
315```
316
317### Accessing a key in a record or an array
318
319**Example** (Reading and updating a record entry)
320
321```ts
322import { Optic } from "effect"
323
324type S = { [key: string]: number }
325
326// Build an optic to access the value at key "a"
327const _a = Optic.id<S>().at("a")
328
329console.log(_a.replace(2, { a: 1 }))
330// { a: 2 }
331```
332
333**Example** (Reading and updating an array element)
334
335```ts
336import { Optic } from "effect"
337
338type S = ReadonlyArray<number>
339
340// Build an optic to access the first element
341const _0 = Optic.id<S>().at(0)
342
343console.log(_0.replace(3, [1, 2]))
344// [3, 2]
345```
346
347### Accessing a member in a tagged union
348
349**Aside** (Convention for tagged unions)
350The convention is to use `"_tag"` as the field that identifies the variant.
351
352**Example** (Focusing a field inside one variant)
353
354```ts
355import { Optic } from "effect"
356
357// A union of two tagged types
358type S =
359 | {
360 readonly _tag: "A"
361 readonly a: number
362 }
363 | {
364 readonly _tag: "B"
365 readonly b: number
366 }
367
368// Build an optic that focuses on the "a" field of the "A" variant
369const _a = Optic.id<S>().tag("A").key("a")
370
371console.log(_a.replace(2, { _tag: "A", a: 1 }))
372// { _tag: 'A', a: 2 }
373
374console.log(_a.replace(2, { _tag: "B", b: 1 })) // no match, so no change
375// { _tag: 'B', b: 1 }
376```
377
378### Traversing a collection
379
380**Example** (Incrementing only positive numbers in an array field)
381
382```ts
383import { Optic, Schema } from "effect"
384
385type S = {
386 readonly a: ReadonlyArray<number>
387}
388
389// Build an optic that focuses the field "a" and then
390// narrows the focus to elements that pass the positivity check
391const _positive = Optic.id<S>()
392 .key("a") // focus the "a" array
393 .forEach((item) => item.check(Schema.isGreaterThan(0))) // keep only positive elements
394
395// Create a function that increments only the focused elements
396const addOne = _positive.modifyAll((n) => n + 1)
397
398console.log(addOne({ a: [1, -2, 3] }))
399// { a: [ 2, -2, 4 ] }
400```
401
402**Technical detail**
403
404Unlike many optic libraries, `Traversal` is not an optic on its own. It is modeled as an `Optional` whose focus is a `ReadonlyArray<A>`:
405
406```ts
407export interface Traversal<in out S, in out A> extends Optional<S, ReadonlyArray<A>> {}
408```
409
410To operate on each `A` inside a `Traversal<S, A>`, use `forEach`.
411`forEach` takes a function whose argument is an `Iso<A, A>`, so you can keep drilling down by composing that `Iso` with other optics.
412
413### Debugging focus failures
414
415If a focus does not exist, `getResult` lets you see success vs failure explicitly:
416
417```ts
418import { Optic, Result } from "effect"
419
420type S = { readonly a?: number }
421const _a = Optic.id<S>().at("a")
422
423const result = _a.getResult({})
424const message = Result.match(result, {
425 onSuccess: (value) => `value: ${value}`,
426 onFailure: () => "no focus"
427})
428
429console.log(message) // no focus
430```
431
432## Generating an Optic from a Schema
433
434**Example** (Generating an Optic from a Struct)
435
436```ts
437import { Schema } from "effect"
438
439const schema = Schema.Struct({
440 a: Schema.String,
441 b: Schema.Number
442})
443
444/*
445const _b: Lens<{
446 readonly a: string;
447 readonly b: number;
448}, number>
449*/
450const _b = Schema.toIso(schema).key("b")
451
452console.log(_b.replace(2, { a: "a", b: 1 }))
453// { a: 'a', b: 2 }
454```
455
456You can also call `Schema.toIso` on custom types when their schema supplies `toCodecIso` or `toCodec` annotations. `Schema.Class` provides these, so class-based schemas work out of the box:
457
458**Example** (Generating an Optic from a Class schema)
459
460```ts
461import { Schema } from "effect"
462
463// Define a class schema
464class Person extends Schema.Class<Person>("Person")({
465 name: Schema.String,
466 age: Schema.Number
467}) {}
468
469const _name = Schema.toIso(Person).key("name")
470
471console.log(_name.replace("b", new Person({ name: "a", age: 1 })))
472// Person { name: 'b', age: 1 }
473```
474
475## Why use functional optics when we already have Immer?
476
477Immer is great: it lets you write "mutating" code that produces new immutable objects under the hood. For many teams that is enough. If you work with nested data, union types, and reusable update logic, **functional optics** (Iso, Lens, Prism, Optional, Traversal) cover use cases that Immer does not aim to address.
478
479Below are the main differences, with small examples.
480
481### Reusable focus instead of ad-hoc navigation
482
483**Immer:** you repeat the path to the field each time you update it.
484
485```ts
486import { produce } from "immer"
487
488type S = {
489 readonly user: {
490 readonly profile: {
491 readonly name: string
492 }
493 }
494}
495
496declare const state: S
497
498const upperName = produce(state, (draft) => {
499 // Navigate to the field inline
500 draft.user.profile.name = draft.user.profile.name.toUpperCase()
501})
502
503const lowerName = produce(state, (draft) => {
504 // Repeat the same navigation again
505 draft.user.profile.name = draft.user.profile.name.toLowerCase()
506})
507```
508
509**Optics:** define a **Lens** once, then reuse it.
510
511```ts
512import { Optic } from "effect"
513
514type S = {
515 readonly user: {
516 readonly profile: {
517 readonly name: string
518 }
519 }
520}
521
522// Define a reusable Lens focusing the "name" field
523// Lens<S, string>
524const _name = Optic.id<S>().key("user").key("profile").key("name")
525
526declare const state: S
527
528// Apply different transformations without repeating the path
529const upperName = _name.modify((name) => name.toUpperCase())(state)
530const lowerName = _name.modify((name) => name.toLowerCase())(state)
531```
532
533Why this matters: if the path changes, you update it in one place. You also get small, testable building blocks that can be shared across modules instead of repeating object navigation.
534
535### Declarative vs manual handling of optional data
536
537**Immer:** manual checks for each optional field.
538
539**Example** (Uppercasing titles with optional fields)
540
541```ts
542import { produce } from "immer"
543
544type S = {
545 readonly todos?: ReadonlyArray<{
546 readonly title?: string
547 readonly description: string
548 }>
549}
550
551const state: S = {
552 todos: [{ title: "milk", description: "buy milk" }, { description: "buy bread" }]
553}
554
555const next = produce(state, (draft) => {
556 // Guard the optional array
557 if (!draft.todos) return
558
559 for (const item of draft.todos) {
560 // Guard the optional field
561 if (item.title !== undefined) {
562 item.title = item.title.toUpperCase()
563 }
564 }
565})
566
567console.log(next)
568/*
569{
570 todos: [
571 { title: 'MILK', description: 'buy milk' },
572 { description: 'buy bread' }
573 ]
574}
575*/
576```
577
578**Optics:** declare the focus; types carry the safety.
579
580**Example** (Uppercasing titles with declarative focus)
581
582```ts
583import { Optic } from "effect"
584
585type S = {
586 readonly todos?: ReadonlyArray<{
587 readonly title?: string
588 readonly description: string
589 }>
590}
591
592const _title = Optic.id<S>()
593 .key("todos")
594 .notUndefined() // proceed only if 'todos' exists
595 .forEach((item) => item.key("title").notUndefined()) // proceed only if 'title' exists
596
597const state: S = {
598 todos: [{ title: "milk", description: "buy milk" }, { description: "buy bread" }]
599}
600
601// Modify only the focused values (titles)
602console.log(_title.modifyAll((title) => title.toUpperCase())(state))
603/*
604{
605 todos: [
606 { title: 'MILK', description: 'buy milk' },
607 { description: 'buy bread' }
608 ]
609}
610*/
611```
612
613### Composition over nesting
614
615**Immer:** you often nest update blocks or repeat the same property paths.
616
617**Optics:** compose small optics into larger ones. Composition keeps code flat and readable.
618
619**Aside** (Reusing optics across modules)
620Define an optic once (for example, a `User.address` lens) and import it wherever you need it. This avoids duplicating paths and centralizes changes when the data shape evolves.