Skip to content

Lens

A Lens<S, A> focuses on a field that is always present inside S. It is the right optic whenever the target field is a required (non-optional) part of the structure — which covers the vast majority of product-type fields.

A Lens provides two primitive operations:

  • get(s) — read the focused value
  • replace(a)(s) — return a new S with the focused value replaced
  • modify(f)(s) — return a new S with the focused value transformed by f

Defining a Lens

Lens<S, A> takes two functions: a getter and a setter. The setter follows the curried form (A) => (S) => S so it composes cleanly without needing a two-argument lambda:

dart
// A Lens<S, A> requires two functions:
//   get  : S -> A         (read the focused field)
//   set  : (A, S) -> S    (replace the focused field, returning a new S)

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),
);

Using a Lens

dart
void lensUsage() {
  // get — read the focused value
  final db = dbConfigL.get(sampleConfig); // DBConfig(...)
  final port = portL.get(sampleConfig.dbConfig); // 5432

  // replace — return a new AppConfig with dbConfig replaced
  final newConfig = dbConfigL.replace(
    const DBConfig(Credentials('admin', 's3cr3t'), 6543, None()),
  )(sampleConfig);

  // modify — apply a function to the focused value
  final bumped = portL.modify((p) => p + 1)(sampleConfig.dbConfig);
}

replace and modify both return a Function1<S, S> — a function from the old structure to the new one. This is the standard optic application style: lens.replace(newValue)(myStruct).

Composing Lenses

Lenses compose with andThenL. The result is a new Lens that focuses through both layers in sequence:

dart
// Lenses compose with andThenL, producing a new Lens that focuses deeper.
final dbPortL = dbConfigL.andThenL(portL);

void composeUsage() {
  final port = dbPortL.get(sampleConfig); // 5432

  // Update the port two levels deep in one step.
  final updated = dbPortL.replace(9999)(sampleConfig);
}

The composition is transparent — callers interact with dbPortL exactly like any other Lens<AppConfig, int>.

Composing with a Getter

andThenG pairs a Lens with a read-only Getter to project a derived value. The result is a Getter, so it supports get but not replace or modify:

dart
// andThenG composes a Lens with a read-only Getter.
// Useful when the target type has derived values you want to project.
final versionMajorG = Lens<AppConfig, String>(
  (cfg) => cfg.version,
  (v) => (cfg) => cfg.copy(version: v),
).andThenG(Getter((String v) => int.parse(v.split('.').first)));

void getterUsage() {
  final major = versionMajorG.get(sampleConfig); // 2
}

TIP

Use andThenG when the target type has a meaningful derived value (a count, a formatted string, a sub-field) that you never need to write back. It keeps the optic read-only by type, preventing accidental mutations.