Overview
Immutable data structures are a cornerstone of functional programming, but updating a deeply nested field means threading a change through every intermediate layer by hand:
// Without optics, updating a nested field requires threading the change
// through every intermediate copy() call by hand.
AppConfig bumpPortManual(AppConfig cfg) => cfg.copy(
dbConfig: cfg.dbConfig.copy(port: cfg.dbConfig.port + 1),
);
This compounds quickly. Three levels deep, with four fields at each level,
already means writing a call to copy() at every layer just to change one
value.
Optics are composable, first-class abstractions for reading and updating immutable data structures. Each optic captures a focus — the part of a larger structure you want to examine or transform — and optics compose so that you can reach arbitrarily deep without repeating the nesting logic everywhere.
The solution
Define a Lens for each layer once, compose them, then call modify or
replace on the result:
// Define a Lens for each layer once...
final dbConfigL = Lens<AppConfig, DBConfig>(
(cfg) => cfg.dbConfig,
(db) => (cfg) => cfg.copy(dbConfig: db),
);
final portL = Lens<DBConfig, int>(
(db) => db.port,
(p) => (db) => db.copy(port: p),
);
// ...then compose them into a single optic that reaches the target directly.
final dbPortL = dbConfigL.andThenL(portL);
AppConfig bumpPortWithLens(AppConfig cfg) => dbPortL.modify((p) => p + 1)(cfg);
The composed dbPortL can be read, replaced, or modified anywhere in the
codebase without repeating the nesting structure.
The optic hierarchy
ribs_optics provides four primary optic types that differ in what they
guarantee about the focus:
// The four primary optic types and their guarantees:
//
// Iso<S,A> — total, bidirectional (S <-> A, no information lost)
// Lens<S,A> — total read-write (A always present inside S)
// Prism<S,A> — partial, constructible (A may or may not match)
// Optional<S,A> — partial read-write (A may or may not be present)
//
// Subtype relationships (every Iso is also a Lens, etc.):
// Iso ⊆ Lens ⊆ Optional ⊇ Prism
| Type | Focus | Read | Write | Construct |
|---|---|---|---|---|
Iso<S,A> | Always present, lossless | ✓ | ✓ | ✓ |
Lens<S,A> | Always present | ✓ | ✓ | — |
Optional<S,A> | May be absent | ✓ (Option) | ✓ (conditional) | — |
Prism<S,A> | One variant of a sum type | ✓ (Option) | ✓ (conditional) | ✓ |
Because every Iso is also a Lens, and every Lens is also an Optional,
optics from different levels of the hierarchy compose seamlessly.
Composition
Optics compose via typed andThen* methods. The naming encodes the result
type:
// Optics compose via typed andThen* methods:
// andThenL — Lens composed with Lens -> Lens
// andThenO — Lens/Optional with Optional -> Optional
// andThenP — Prism composed with Prism -> Prism
// andThenG — any optic with Getter -> Getter (read-only)
// andThenS — any Setter with Setter -> Setter (write-only)
| Method | Left | Right | Result |
|---|---|---|---|
andThenL | Lens | Lens | Lens |
andThenO | Lens / Optional | Optional | Optional |
andThenP | Prism | Prism | Prism |
andThen | Iso | Iso | Iso |
andThenG | any optic | Getter | Getter |
andThenS | any Setter | Setter | Setter |
Choosing an optic
- The target is always present in the structure → Lens
- The target may be absent (
Option<A>field or nullable) → Optional - The structure is a sealed class / sum type and you want one variant → Prism
- You have two types that are completely interchangeable → Iso