Skip to main content

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:

Manual nested update
// 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:

Composing lenses
// 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:

Optic types
// 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
TypeFocusReadWriteConstruct
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:

Composition methods
// 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)
MethodLeftRightResult
andThenLLensLensLens
andThenOLens / OptionalOptionalOptional
andThenPPrismPrismPrism
andThenIsoIsoIso
andThenGany opticGetterGetter
andThenSany SetterSetterSetter

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