From 1fa22386fec0bccf216043132483883c305acd21 Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Wed, 15 Apr 2026 10:19:54 -0700 Subject: [PATCH] feat(night-shift): Author the optics documentation corpus --- docs/README.md | 66 +++++--- docs/api-reference.md | 315 ++++++++++++++++++++++++++++++++++--- docs/best-practices.md | 87 +++++++--- docs/combinators.md | 148 ++++++++++++++--- docs/composition.md | 217 ++++++++++++++++++++++--- docs/quick-start.md | 134 ++++++++++++++-- docs/semantics-and-laws.md | 182 ++++++++++++++++++--- 7 files changed, 1007 insertions(+), 142 deletions(-) diff --git a/docs/README.md b/docs/README.md index 0f1cb59..4e2f275 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,37 +1,51 @@ # Optics documentation -This directory is the single source of truth for long-form documentation. -The same Markdown files should serve both of these consumers: +This directory is the long-form reference for `@fuiste/optics`. +The root [`README.md`](../README.md) stays responsible for installation and first contact; these pages exist for the parts that benefit from stable permalinks, denser examples, and a less hurried explanation. -- the in-repository docs experience -- the eventual GitHub Pages wrapper +## Mental model -The root [`README.md`](../README.md) remains the primary installation and quick-start surface. -This docs home exists for everything that benefits from a stable permalink and a broader information architecture. +Every exported optic can be understood along the same semantic axes: + +| Kind | Cardinality | Read/write | Typical use | +| ----------- | ----------- | ---------- | ---------------------------------- | +| `Lens` | total | writable | required properties | +| `Prism` | partial | writable | optional fields and union branches | +| `Iso` | total | invertible | representational changes | +| `Traversal` | many | writable | zero or more mutable foci | +| `Getter` | total | read-only | computed values | +| `Fold` | many | read-only | extracted collections | + +Those categories matter more than the spelling of a constructor. `compose(outer, inner)` combines the categories first and the concrete methods second, which is why `Getter` and `Fold` infect a composition with read-only behaviour and why a `Traversal` turns a single focus into a many-focus result. ## Start here -- [Quick start](quick-start.md) for moving from install to useful optics quickly -- [Composition](composition.md) for the optic composition model and result-kind matrix -- [Combinators](combinators.md) for `guard`, `at`, `index`, and `each` -- [API reference](api-reference.md) for the exported surface area -- [Semantics and laws](semantics-and-laws.md) for operational guarantees and law-like expectations -- [Best practices](best-practices.md) for pragmatic guidance when composing optics in application code +- [Quick start](quick-start.md) introduces the six optic kinds and the smallest useful compositions. +- [Composition](composition.md) explains the full result-kind matrix, read-only degradation, and the `Prism ∘ Iso` materialization exception. +- [Combinators](combinators.md) covers the standard helpers: `guard`, `at`, `index`, and `each`. +- [API reference](api-reference.md) documents every public constructor, helper, and exported utility type from `src/index.ts`. +- [Semantics and laws](semantics-and-laws.md) records immutability, no-op behaviour, identity preservation, and round-trip expectations. +- [Best practices](best-practices.md) turns those semantics into practical usage guidance. + +## Coverage map + +The public module exports: + +- Factories and constructors: `Lens`, `Prism`, `Iso`, `Traversal`, `Getter`, `Fold` +- Standalone helpers: `compose`, `guard`, `at`, `index`, `each` +- Types: `Optic`, `InferSource`, `InferTarget` + +The pages in this directory cover all of them without introducing any extra runtime API. + +## How the pages fit together + +If you are deciding which optic to start with, use [Quick start](quick-start.md). +If you already have optics and need to know what their composition becomes, use [Composition](composition.md). +If your data shape already looks like "optional branch", "record key", "array slot", or "all array elements", use [Combinators](combinators.md). +If you need signatures and behaviour in one place, use [API reference](api-reference.md). -## Content contract +## Documentation contract - Canonical prose lives in `docs/*.md`. - Navigation metadata lives in [`navigation.json`](navigation.json) and points at the same Markdown files. -- A site wrapper should project this tree into routes; it should not introduce a second prose corpus. - -## Initial page set - -| Page | Stable path | Purpose | -| ------------------ | ---------------------------- | ------------------------------------------------------ | -| Docs home | `docs/README.md` | Repository-visible home and site root source | -| Quick start | `docs/quick-start.md` | Short path from install to first useful composition | -| Composition | `docs/composition.md` | Composition rules, result kinds, and chain-building | -| Combinators | `docs/combinators.md` | Focused guidance for the standard combinators | -| API reference | `docs/api-reference.md` | Exported types, constructors, and standalone functions | -| Semantics and laws | `docs/semantics-and-laws.md` | Behavioural guarantees, law framing, and edge cases | -| Best practices | `docs/best-practices.md` | Advice for ergonomic and predictable usage | +- Other wrappers may project this tree into routes, but they should not fork the prose into a second corpus. diff --git a/docs/api-reference.md b/docs/api-reference.md index 9340f81..f10b0ab 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,42 +1,305 @@ # API reference -This page names the stable exported surface from `@fuiste/optics`. -It is intentionally organized around the repository's public module rather than an external site generator's sidebar shape. +This page documents the stable public surface exported from `src/index.ts`. +It stays close to the module boundary rather than to any particular docs wrapper, so the entries here correspond directly to what callers import. ## Exported optic types -- `Lens` -- `Prism` -- `Iso` -- `Traversal` -- `Getter` -- `Fold` -- `Optic` +### `Lens` + +```ts +type Lens = { + _tag: 'lens' + get: (s: S) => A + set: (a: A | ((a: A) => A)) => (s: T) => T +} +``` + +Total writable focus. `get` always succeeds, and `set` accepts either a concrete value or an updater function. + +### `Prism` + +```ts +type Prism = { + _tag: 'prism' + get: (s: S) => A | undefined + set: (a: A | ((a: A) => A)) => (s: T) => T +} +``` + +Partial writable focus. `get` may return `undefined`. Function-updater writes are a no-op when the branch is absent. + +### `Iso` + +```ts +type Iso = { + _tag: 'iso' + to: (s: S) => A + from: (a: A) => S +} +``` + +Total reversible mapping. The intended law is that `from(to(s)) === s` and `to(from(a)) === a`. + +### `Traversal` + +```ts +type Traversal = { + _tag: 'traversal' + getAll: (s: S) => ReadonlyArray + modify: (f: (a: A) => A) => (s: T) => T +} +``` + +Writable multi-focus. `getAll` extracts every focus; `modify` applies a function to each focus. + +### `Getter` + +```ts +type Getter = { + _tag: 'getter' + get: (s: S) => A +} +``` + +Read-only total focus. There is no `set`. + +### `Fold` + +```ts +type Fold = { + _tag: 'fold' + getAll: (s: S) => ReadonlyArray +} +``` + +Read-only multi-focus. There is no `modify`. + +### `Optic` + +```ts +type Optic = + | Lens + | Prism + | Iso + | Traversal + | Getter + | Fold +``` + +The sum type of all supported optics. `compose` accepts any pair drawn from this union. ## Constructors and factories -- `Lens().prop(key)` creates a total property focus. -- `Prism().of({ get, set })` creates a partial focus. -- `Iso({ to, from })` creates an invertible mapping. -- `Traversal({ getAll, modify })` creates a writable multi-focus. -- `Getter(get)` creates a read-only total focus. -- `Fold(getAll)` creates a read-only multi-focus. +### `Lens` + +```ts +Lens().prop(key: K): Lens +``` + +Creates a property lens. + +- Focus is total: the property is assumed to exist. +- `set` preserves reference identity when the replacement is `Object.is`-equal to the current value. +- Numeric keys also work for arrays, though [Best practices](best-practices.md) recommends `index` or `each` for collection-oriented code. + +```ts +import { Lens } from '@fuiste/optics' + +type Person = { name: string; age: number } + +const name = Lens().prop('name') +name.get({ name: 'Ada', age: 36 }) // 'Ada' +name.set('Grace')({ name: 'Ada', age: 36 }) // { name: 'Grace', age: 36 } +``` + +### `Prism` + +```ts +Prism().of({ + get: (s: S) => A | undefined + set: (a: A) => (s: S) => S +}): Prism +``` + +Creates a custom prism from explicit `get` and `set` functions. + +- Use it for optional fields or branch selection when a standard combinator is not enough. +- Concrete writes delegate to your `set`, so a custom prism may materialize a missing branch. +- Function-updater writes only run when `get` finds a current value. +- Identity is preserved when an update computes the same focused value. + +```ts +import { Prism } from '@fuiste/optics' + +type Person = { address?: { city: string } } + +const address = Prism().of({ + get: (person) => person.address, + set: (next) => (person) => ({ ...person, address: next }), +}) +``` + +### `Iso` + +```ts +Iso({ + to: (s: S) => A + from: (a: A) => S +}): Iso +``` + +Creates an isomorphism. + +- Use it when two representations carry the same information. +- `Iso ∘ Iso` is the only composition that stays an `Iso`. +- When composed under a `Prism`, a concrete write can materialize a missing branch because `from` can synthesize the intermediate value. + +```ts +import { Iso } from '@fuiste/optics' + +const numberString = Iso({ + to: (n) => `${n}`, + from: (s) => parseInt(s, 10), +}) +``` + +### `Traversal` + +```ts +Traversal({ + getAll: (s: S) => ReadonlyArray + modify: (f: (a: A) => A) => (s: T) => T +}): Traversal +``` + +Creates a writable multi-focus optic. + +- `modify` runs the mapper across every focused value. +- When no focused values change, traversal implementations may preserve the original source reference. The built-in `each()` combinator does so. +- Composing with a traversal usually yields another traversal unless a read-only optic is involved. + +### `Getter` + +```ts +Getter(get: (s: S) => A): Getter +``` + +Creates a read-only total optic. + +- Use it for derived values that should never be set. +- `Getter ∘ Lens`, `Lens ∘ Getter`, `Iso ∘ Getter`, `Getter ∘ Iso`, and `Getter ∘ Getter` remain `Getter`. +- Composing a `Getter` with partial or many-valued optics degrades to `Fold`. + +```ts +import { Getter } from '@fuiste/optics' + +type Person = { firstName: string; lastName: string } + +const fullName = Getter((person) => `${person.firstName} ${person.lastName}`) +``` + +### `Fold` + +```ts +Fold(getAll: (s: S) => ReadonlyArray): Fold +``` + +Creates a read-only multi-focus optic. + +- Use it for extraction, not mutation. +- `Fold` is absorbing under composition: any composition involving `Fold` yields `Fold`. + +```ts +import { Fold } from '@fuiste/optics' + +const words = Fold((s) => s.split(' ')) +words.getAll('hello world') // ['hello', 'world'] +``` ## Standalone helpers -- `compose(outer, inner)` composes two optics and infers the result kind. -- `guard(predicate)` lifts a type guard into a `Prism`. -- `at(key)` focuses on a `Record` entry. -- `index(idx)` focuses on a single array element. -- `each()` traverses every element of a readonly array. +### `compose` + +```ts +compose(outer: Optic, inner: Optic): Optic +``` + +Universal composition over all optic pairs. + +- The library exposes overloads for the whole composition matrix. +- The result kind is inferred from the pair of tags rather than from surface syntax. +- See [Composition](composition.md) for the matrix and behaviour notes. + +### `guard` + +```ts +guard(predicate: (s: S) => s is A): Prism +``` + +Lifts a TypeScript type guard into a prism. + +- `get` returns the matching branch or `undefined`. +- Concrete `set` replaces the source with the provided branch, even if the current value does not match. +- Function-updater writes are a no-op when the predicate fails. + +### `at` + +```ts +at(key: string): Prism>, V> +``` + +Creates a prism over a record entry. + +- `get` returns the value at `key` or `undefined`. +- Concrete `set` upserts the key. +- Function-updater writes are a no-op when the key is absent. + +### `index` + +```ts +index(idx: number): Prism, A> +``` + +Creates a prism over one array element. + +- Out-of-bounds reads return `undefined`. +- Out-of-bounds writes are a no-op. +- Unchanged writes preserve the original array reference. + +### `each` + +```ts +each(): Traversal, A> +``` + +Creates a traversal over all array elements. + +- `getAll` returns every element. +- `modify` maps every element. +- If the mapper leaves every element `Object.is`-equal to the original, the original array reference is preserved. ## Utility types -- `InferSource` -- `InferTarget` +### `InferSource` + +Extracts the source type `S` from any optic. + +```ts +type Source = InferSource>> // ReadonlyArray +``` + +### `InferTarget` + +Extracts the focus type `A` from any optic. + +```ts +type Target = InferTarget>> // number +``` -## Documentation split +## Related pages -- [Quick start](quick-start.md) is the shortest path to first usage. -- [Composition](composition.md) explains result inference. -- [Semantics and laws](semantics-and-laws.md) documents behavioural guarantees that matter for callers. +- [Quick start](quick-start.md) for the first decision about which optic to use. +- [Composition](composition.md) for result-kind inference and read-only outcomes. +- [Combinators](combinators.md) for deeper coverage of `guard`, `at`, `index`, and `each`. +- [Semantics and laws](semantics-and-laws.md) for immutability, no-op updates, and round-trip expectations. diff --git a/docs/best-practices.md b/docs/best-practices.md index 7078f5a..389d960 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -1,31 +1,82 @@ # Best practices -Use optics as small composable values, not as an excuse to hide arbitrary business logic behind a setter-shaped curtain. +Use optics as small composable values, not as an excuse to launder arbitrary business logic through a setter-shaped API. -## Prefer small optics +## Match the optic to the semantics -Compose tiny optics with `compose` instead of building one large custom optic for an entire path. -Smaller optics are easier to test, reuse, and reason about. +Choose the constructor that matches the data shape you actually have: -## Match the constructor to the shape +- `Lens` for total required focus +- `Prism` for optional focus or sum-type branch selection +- `Iso` for reversible representation changes +- `Traversal` for many writable foci +- `Getter` for one derived read-only value +- `Fold` for many derived read-only values -- Use `Lens` when the focus is total. -- Use `Prism` when the focus may be absent. -- Use `Traversal` when there are many writable targets. -- Use `Getter` or `Fold` when read-only is semantically correct. +If you pick a more powerful optic than the data deserves, you will usually end up lying to the type system first and to future readers second. -## Reach for combinators first +## Compose one step at a time -Prefer `guard`, `at`, `index`, and `each` when they fit. -They encode the partiality and multiplicity rules directly, which is preferable to re-deriving them poorly in application code. +Prefer several small optics composed with `compose(outer, inner)` over one bespoke optic that tries to explain an entire path at once. -## Preserve purity +Benefits: -- Do not mutate inside `set`, `modify`, or custom constructors. +- each step has a precise tag and law surface +- reuse stays easy +- tests can target the tricky step instead of the whole pipeline +- the result kind follows the matrix in [Composition](composition.md) instead of private convention + +## Reach for combinators before custom optics + +Prefer the exported helpers when they fit: + +- `guard` for discriminated unions +- `at` for record entries +- `index` for one optional array position +- `each` for all array elements + +They already encode the correct absent-branch and identity-preservation behaviour. + +## Keep setters and modifiers pure + +Custom `Prism`, `Traversal`, or `Iso` constructors are only as sound as the functions you supply. + +- Do not mutate inside `set` or `modify`. - Treat updater functions as pure transformations. -- Rely on no-op behaviour for absent partial paths instead of smuggling in sentinel defaults. +- Preserve untouched structure when possible. +- For `Iso`, make `to` and `from` actual inverses rather than approximate cousins. + +The tests assume these contracts. A dishonest optic can still type-check; it just ceases to be an optic in any respectable sense. + +## Prefer explicit partiality over invented defaults + +When a branch may be absent, let `Prism#get` return `undefined` and let composed writes no-op when the path is missing. +Do not smuggle default objects into composed setters merely to make updates "convenient". That trades explicit absence for hidden data synthesis. + +The deliberate exception is `Prism ∘ Iso`, where a concrete write can materialize because the `Iso` provides a lawful way back to the intermediate representation. If you need more aggressive materialization than that, model it directly in your own `Prism`. + +## Use collection optics deliberately + +For arrays and array-like shapes: + +- use `index(i)` when you mean one optional element +- use `each()` when you mean all elements +- use `Lens().prop(i)` only when the index is truly total in your model + +The library supports numeric `Lens.prop` on arrays, but in most application code the partiality of `index` is the more honest description. + +## Let read-only remain read-only + +Do not fight the result of `compose` when it degrades to `Getter` or `Fold`. +That degradation is telling you something true: + +- you are reading derived data +- or the path is partial or many-valued enough that a writable single-focus API would be dishonest + +If you need mutation, revisit the optic choice earlier in the chain rather than trying to reconstruct mutation at the end. -## Stable neighbors +## Related pages -- [Combinators](combinators.md) covers the standard constructors. -- [Semantics and laws](semantics-and-laws.md) explains the behavioural guarantees these practices rely on. +- [API reference](api-reference.md) for the full exported surface. +- [Combinators](combinators.md) for the standard helpers. +- [Semantics and laws](semantics-and-laws.md) for the guarantees these practices rely on. diff --git a/docs/combinators.md b/docs/combinators.md index 49bb666..d2b8a5b 100644 --- a/docs/combinators.md +++ b/docs/combinators.md @@ -1,12 +1,31 @@ # Combinators -The standard combinators capture the common cases where writing a custom optic would be ceremonial at best. -They are the small basis that keeps most code from degenerating into ad hoc shape surgery. +The exported combinators cover the shapes that appear constantly in application code: + +- union branches +- record keys +- single array indices +- all array elements + +They matter because they encode the right partiality and multiplicity semantics by construction, which is preferable to re-deriving them with ad hoc setters and hoping impurity does not sneak in sideways. + +## Quick chooser + +| Helper | Result kind | Focus shape | Read behaviour | Write behaviour | +| ------- | ----------- | ----------- | -------------------------------- | -------------------------------------------------------------- | +| `guard` | `Prism` | one branch | `undefined` when predicate fails | concrete set replaces; updater set no-ops when predicate fails | +| `at` | `Prism` | one key | `undefined` when key is absent | concrete set upserts; updater set no-ops when key is absent | +| `index` | `Prism` | one slot | `undefined` when out of bounds | no-op when out of bounds | +| `each` | `Traversal` | all slots | returns every element | `modify` maps every element | ## `guard` -`guard` lifts a TypeScript type guard into a `Prism`. -Use it for discriminated unions instead of hand-rolled `Prism().of(...)` definitions. +```ts +guard(predicate: (s: S) => s is A): Prism +``` + +`guard` lifts a TypeScript type guard into a prism. +It is the intended constructor for discriminated unions. ```ts import { Lens, compose, guard } from '@fuiste/optics' @@ -17,31 +36,124 @@ type Shape = Circle | Square const circle = guard((shape): shape is Circle => shape.type === 'circle') const radius = compose(circle, Lens().prop('radius')) + +radius.get({ type: 'circle', radius: 5 }) // 5 +radius.get({ type: 'square', side: 4 }) // undefined ``` +Behaviour notes grounded in the tests: + +- `get` returns the matching branch or `undefined`. +- A concrete `set` replaces the source with the supplied matching branch, even when the original source was a different branch. +- A function-updater `set` only runs when the predicate matches; otherwise it is a no-op. + +That asymmetric behaviour is intentional. Concrete replacement is always possible because you already supplied a valid `A`. + ## `at` -`at(key)` creates a prism over a record entry. -Missing keys read as `undefined`; concrete writes upsert; updater writes are no-ops when the key is absent. +```ts +at(key: string): Prism>, V> +``` + +`at` focuses on a key in a record-like object. + +```ts +import { at } from '@fuiste/optics' + +const auth = at('Authorization') + +auth.get({ Authorization: 'Bearer x' }) // 'Bearer x' +auth.get({}) // undefined +auth.set('Bearer y')({}) // { Authorization: 'Bearer y' } +``` + +Semantics: + +- Reads return `undefined` when the key is absent. +- Concrete writes upsert the key. +- Function-updater writes are a no-op when the key is absent. +- If the updated value is unchanged, the original object reference is preserved. + +Because the result is a `Prism`, composing `at` with a `Lens` or `Iso` still yields a partial optic. ## `index` -`index(i)` creates a prism over an array slot. -It is partial by construction, so out-of-bounds reads return `undefined` and writes become no-ops. +```ts +index(idx: number): Prism, A> +``` + +`index` focuses on one optional array element. + +```ts +import { index } from '@fuiste/optics' + +const second = index(1) + +second.get([10, 20, 30]) // 20 +second.get([10]) // undefined +second.set(99)([10, 20, 30]) // [10, 99, 30] +second.set(99)([10]) // unchanged +``` + +Semantics: + +- Out-of-bounds reads return `undefined`. +- Out-of-bounds writes are a no-op. +- Concrete and updater writes both preserve the original array when the focused value is unchanged. + +Use `index` when you mean "maybe one element". Use `each()` when you mean "all elements". If you reach for `Lens().prop(i)`, you are encoding totality that the data structure generally does not deserve. ## `each` -`each()` creates a traversal over every element of a readonly array. -Compose it when you want to transform every focus rather than a single optional position. +```ts +each(): Traversal, A> +``` + +`each` creates a traversal over every array element. + +```ts +import { each } from '@fuiste/optics' + +const nums = each() + +nums.getAll([1, 2, 3]) // [1, 2, 3] +nums.modify((n) => n * 2)([1, 2, 3]) // [2, 4, 6] +``` + +Semantics: + +- `getAll` returns the original sequence of focused values. +- `modify` applies the mapper to every element. +- When the mapper leaves every element unchanged, `each()` returns the original array reference. + +`each` is often the bridge from singular to many-valued composition: + +```ts +import { Lens, compose, each } from '@fuiste/optics' + +type Team = { members: Array<{ name: string }> } + +const memberNames = compose( + compose(Lens().prop('members'), each<{ name: string }>()), + Lens<{ name: string }>().prop('name'), +) + +memberNames.getAll({ members: [{ name: 'Ada' }, { name: 'Grace' }] }) // ['Ada', 'Grace'] +``` + +## Combinators in composition + +The combinators follow the same matrix as hand-built optics: -## Choosing the right combinator +- `guard ∘ Lens` is `Prism` +- `at ∘ Iso` is `Prism` +- `Lens ∘ each` is `Traversal` +- `Getter ∘ index` is `Fold` -- Use `guard` for discriminated unions. -- Use `at` for keyed records. -- Use `index` for one optional array position. -- Use `each` for all elements in an array. +See [Composition](composition.md) for the full matrix and [Semantics and laws](semantics-and-laws.md) for absent-path behaviour once these helpers are composed. -## Stable neighbors +## Related pages -- [Composition](composition.md) explains how combinators affect result kinds once composed. -- [Best practices](best-practices.md) covers when a combinator is preferable to a bespoke optic. +- [Composition](composition.md) for result-kind inference. +- [API reference](api-reference.md) for signatures and export coverage. +- [Best practices](best-practices.md) for when to prefer combinators over custom optics. diff --git a/docs/composition.md b/docs/composition.md index 63c0388..eb8bcb1 100644 --- a/docs/composition.md +++ b/docs/composition.md @@ -1,12 +1,12 @@ # Composition -Composition is the library's central operation. -Rather than smuggling path logic through custom getters and setters, compose small optics and let the result kind fall out of the pair you chose. +Composition is the center of the library. +Instead of writing bespoke nested accessors and hoping the edge cases line up, you compose small optics and let the result kind be inferred from the pair. -## One operator, many result kinds +## The result-kind matrix -All exported optics compose through `compose(outer, inner)`. -The result kind is determined by the participating optics. +All six optic kinds compose through `compose(outer, inner)`. +The tests cover the full matrix below. | outer \\ inner | `Lens` | `Prism` | `Iso` | `Traversal` | `Getter` | `Fold` | | -------------- | ----------- | ----------- | ----------- | ----------- | -------- | ------ | @@ -17,15 +17,100 @@ The result kind is determined by the participating optics. | `Getter` | `Getter` | `Fold` | `Getter` | `Fold` | `Getter` | `Fold` | | `Fold` | `Fold` | `Fold` | `Fold` | `Fold` | `Fold` | `Fold` | -## Rules of thumb +This is not merely type-level decoration. The tag determines which operations exist on the composed optic: -- `Fold` is contagious: once a read-many optic appears, the result stays read-only and many-valued. -- `Getter` plus any partial or many-valued optic degrades to `Fold`. -- `Traversal` absorbs writable optics and keeps the result writable-many. -- `Iso` is transparent except when both sides are `Iso`. -- `Lens` composed with `Lens` stays total; any partial branch produces `Prism`. +- `Lens` and `Prism` expose `get` and `set` +- `Traversal` exposes `getAll` and `modify` +- `Getter` exposes only `get` +- `Fold` exposes only `getAll` +- `Iso` exposes `to` and `from` -## Example +## Why the matrix looks this way + +### Total, partial, and many + +- `Lens` is total and singular. +- `Prism` is partial and singular. +- `Traversal` is many-valued. +- `Getter` is total and read-only. +- `Fold` is many-valued and read-only. +- `Iso` is total and reversible, so it acts like a transparent representation change unless paired with another `Iso`. + +The result kind is the least surprising optic that can still represent the combined behaviour. + +### Read-only is contagious + +`Getter` and `Fold` never grow setters by composition. + +- `Getter ∘ Lens` is still `Getter`. +- `Getter ∘ Iso` is still `Getter`. +- `Lens ∘ Getter` is `Getter`. +- As soon as partiality or multiplicity enters a `Getter` composition, the result becomes `Fold`. +- Any pair involving `Fold` yields `Fold`. + +Representative examples: + +```ts +import { Fold, Getter, Lens, Prism, compose } from '@fuiste/optics' + +type Person = { firstName: string; lastName: string; address?: { city: string } } + +const fullName = Getter((person) => `${person.firstName} ${person.lastName}`) +const city = compose( + Getter((person) => person), + Prism().of({ + get: (person) => person.address, + set: (address) => (person) => ({ ...person, address }), + }), +) + +fullName._tag // 'getter' +city._tag // 'fold' +``` + +The second result is `Fold` because a read-only optic composed with a partial optic cannot promise a total single focus. + +### Traversal absorbs writable optics + +When a writable many-focus optic participates, the result stays writable-many unless read-only forces degradation. + +- `Lens ∘ Traversal` is `Traversal` +- `Traversal ∘ Prism` is `Traversal` +- `Traversal ∘ Traversal` is `Traversal` +- `Traversal ∘ Getter` is `Fold` + +This reflects the shape of the operations: once you can focus on many writable values, later single-focus writable optics simply refine each element. + +### `Iso` is transparent, except when it is not + +`Iso` behaves like a representational isomorphism, so the other optic kind usually wins: + +- `Lens ∘ Iso` is `Lens` +- `Iso ∘ Lens` is `Lens` +- `Prism ∘ Iso` is `Prism` +- `Iso ∘ Traversal` is `Traversal` + +Only `Iso ∘ Iso` remains `Iso`, because invertibility survives on both sides. + +## Representative compositions + +### `Lens ∘ Lens => Lens` + +```ts +import { Lens, compose } from '@fuiste/optics' + +type Address = { city: string } +type Person = { address: Address } + +const city = compose(Lens().prop('address'), Lens
().prop('city')) + +city.get({ address: { city: 'London' } }) // 'London' +city.set('Paris')({ address: { city: 'London' } }) // { address: { city: 'Paris' } } +``` + +Because both optics are total and singular, the result remains total and singular. + +### `Prism ∘ Lens => Prism` ```ts import { Lens, Prism, compose } from '@fuiste/optics' @@ -38,16 +123,106 @@ const address = Prism().of({ set: (next) => (person) => ({ ...person, address: next }), }) -const city = Lens
().prop('city') -const personCity = compose(address, city) +const city = compose(address, Lens
().prop('city')) -personCity.get({ address: { city: 'London' } }) // 'London' -personCity.get({}) // undefined -personCity.set('Paris')({}) // unchanged +city.get({ address: { city: 'London' } }) // 'London' +city.get({}) // undefined +city.set('Paris')({}) // unchanged ``` -## Stable neighbors +The outer partiality dominates. Once the branch is missing, a composed write is a no-op rather than an invented nested update. + +### `Lens ∘ Getter => Getter` + +```ts +import { Getter, Lens, compose } from '@fuiste/optics' + +type Person = { firstName: string; lastName: string } +type Team = { lead: Person } + +const fullName = Getter((person) => `${person.firstName} ${person.lastName}`) +const leadName = compose(Lens().prop('lead'), fullName) + +leadName.get({ lead: { firstName: 'Ada', lastName: 'Lovelace' } }) // 'Ada Lovelace' +``` + +The result is read-only because the inner optic is read-only. + +### `Lens ∘ Fold => Fold` + +```ts +import { Fold, Lens, compose } from '@fuiste/optics' + +type Team = { aliases: string } + +const aliasWords = compose( + Lens().prop('aliases'), + Fold((s) => s.split(' ')), +) + +aliasWords.getAll({ aliases: 'lead mentor reviewer' }) // ['lead', 'mentor', 'reviewer'] +``` + +Read-many stays read-many. + +### `Lens ∘ Traversal => Traversal` + +```ts +import { Lens, compose, each } from '@fuiste/optics' + +type Team = { members: string[] } + +const allMembers = compose(Lens().prop('members'), each()) + +allMembers.getAll({ members: ['Ada', 'Grace'] }) // ['Ada', 'Grace'] +allMembers.modify((name) => name.toUpperCase())({ members: ['Ada', 'Grace'] }) +// { members: ['ADA', 'GRACE'] } +``` + +`Traversal#modify` maps every focus and preserves identity when no focused element changes. + +## The `Prism ∘ Iso` exception + +Most composed prism writes are no-ops when the outer branch is missing. +One exception is deliberate: `Prism ∘ Iso` can materialize a concrete value because the `Iso` can reconstruct the missing intermediate. + +```ts +import { Iso, Prism, compose } from '@fuiste/optics' + +type Model = { count?: number } + +const count = Prism().of({ + get: (model) => model.count, + set: (next) => (model) => ({ ...model, count: next }), +}) + +const asString = Iso({ + to: (n) => `${n}`, + from: (s) => parseInt(s, 10), +}) + +const countText = compose(count, asString) + +countText.get({}) // undefined +countText.set('9')({}) // { count: 9 } +countText.set((value) => `${parseInt(value, 10) + 1}`)({}) // unchanged +``` + +Concrete writes can materialize because `from('9')` yields a number that the outer prism can set. +Function-updater writes still require an existing focus and remain a no-op when absent. + +## Composition discipline + +The practical rule is simple: + +- Build the smallest optic that describes one step. +- Compose outward-to-inward with `compose(outer, inner)`. +- Let the resulting tag tell you whether you now have `set`, `modify`, `get`, or `getAll`. + +If you find yourself guessing the result kind, consult the matrix instead of relying on intuition. Mutable code has already done enough damage without type-level wishful thinking joining in. + +## Related pages -- [Quick start](quick-start.md) introduces the smallest useful composition. -- [Semantics and laws](semantics-and-laws.md) explains why absent composed prism paths are no-ops. -- [API reference](api-reference.md) records the exported factories and helpers. +- [Quick start](quick-start.md) for the first few compositions. +- [Combinators](combinators.md) for the helpers that commonly appear in compositions. +- [Semantics and laws](semantics-and-laws.md) for no-op behaviour, identity preservation, and round-trip expectations. diff --git a/docs/quick-start.md b/docs/quick-start.md index c3146ee..99ae42a 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -1,16 +1,28 @@ # Quick start -Use this page after you have installed `@fuiste/optics` via the root [`README.md`](../README.md). -The README remains the shortest path to installation and first contact; this page is the stable location for quick-start material that other docs pages can link to. +Use this page after installing `@fuiste/optics` from the root [`README.md`](../README.md). +The aim here is not to restate the whole README, but to give you a compact path from "I have nested immutable data" to "I know which optic I need". -## Minimal progression +## The shortest taxonomy -1. Start with a total focus via `Lens`. -2. Introduce `Prism` when a branch may be absent. -3. Compose optics with `compose(outer, inner)` rather than building bespoke nested accessors. -4. Reach for combinators such as `each`, `index`, `at`, and `guard` when the shape already matches their algebra. +- Use `Lens` for a required value. +- Use `Prism` for an optional value or a union branch. +- Use `Iso` for a total reversible representation change. +- Use `Traversal` for zero or more writable values. +- Use `Getter` for a computed read-only value. +- Use `Fold` for extracted read-only collections. -## A first composition +The distinction is categorical rather than cosmetic: + +- total vs partial +- one focus vs many foci +- writable vs read-only + +[Composition](composition.md) is where those axes become a matrix. + +## Start with a `Lens` + +`Lens` is the default when the path is required all the way down. ```ts import { Lens, compose } from '@fuiste/optics' @@ -29,10 +41,110 @@ const profile: Profile = { user: { name: 'Ada' } } profileName.get(profile) // 'Ada' profileName.set('Grace')(profile) // { user: { name: 'Grace' } } +profileName.set((name) => name.toUpperCase())(profile) // { user: { name: 'ADA' } } +``` + +If the update does not actually change the focused value, the library preserves the original reference where it can prove that fact. The tests assert this for `Lens`, `Prism`, `index`, and `each`; see [Semantics and laws](semantics-and-laws.md). + +## Switch to `Prism` when a branch can disappear + +`Prism` models partial focus. Reads may fail, so `get` returns `undefined`. + +```ts +import { Lens, Prism, compose } from '@fuiste/optics' + +type Address = { city: string } +type Person = { name: string; address?: Address } + +const addressPrism = Prism().of({ + get: (person) => person.address, + set: (address) => (person) => ({ ...person, address }), +}) + +const cityPrism = compose(addressPrism, Lens
().prop('city')) + +cityPrism.get({ name: 'Ada', address: { city: 'London' } }) // 'London' +cityPrism.get({ name: 'Ada' }) // undefined +cityPrism.set('Paris')({ name: 'Ada' }) // unchanged ``` +That final line is important. A bare `Prism` can still materialize a missing branch if its own setter knows how to do so, but a composed partial path does not invent missing intermediate structure by default. The main exception is `Prism ∘ Iso`, covered in [Composition](composition.md). + +## Use combinators when the shape is already standard + +Four helpers cover the common constructors: + +- `guard(predicate)` for discriminated unions +- `at(key)` for record entries +- `index(i)` for a single array slot +- `each()` for all elements of an array + +```ts +import { Lens, compose, each, guard, index } from '@fuiste/optics' + +type Circle = { type: 'circle'; radius: number } +type Square = { type: 'square'; side: number } +type Shape = Circle | Square +type Scene = { shapes: Shape[] } + +const shapes = Lens().prop('shapes') +const allShapes = compose(shapes, each()) +const circles = compose( + allShapes, + guard((shape): shape is Circle => shape.type === 'circle'), +) +const radii = compose(circles, Lens().prop('radius')) + +radii.getAll({ + shapes: [ + { type: 'circle', radius: 2 }, + { type: 'square', side: 3 }, + ], +}) // [2] + +const secondShape = compose(shapes, index(1)) +secondShape.get({ + shapes: [ + { type: 'circle', radius: 2 }, + { type: 'square', side: 3 }, + ], +}) +// { type: 'square', side: 3 } +``` + +## Read-only optics are first-class, not second-class + +Use `Getter` when you want exactly one derived value with no setter. +Use `Fold` when you want zero or more derived values with no modifier. + +```ts +import { Fold, Getter, Lens, compose } from '@fuiste/optics' + +type Person = { firstName: string; lastName: string } +type Team = { lead: Person; aliases: string } + +const fullName = Getter((person) => `${person.firstName} ${person.lastName}`) +const leadName = compose(Lens().prop('lead'), fullName) + +leadName.get({ lead: { firstName: 'Ada', lastName: 'Lovelace' }, aliases: 'analyst mathematician' }) +// 'Ada Lovelace' + +const aliasWords = compose( + Lens().prop('aliases'), + Fold((s) => s.split(' ')), +) +aliasWords.getAll({ + lead: { firstName: 'Ada', lastName: 'Lovelace' }, + aliases: 'analyst mathematician', +}) +// ['analyst', 'mathematician'] +``` + +When read-only optics appear in composition, the result degrades to `Getter` or `Fold` depending on whether multiplicity or partiality has entered the picture. + ## Where to go next -- Continue to [Composition](composition.md) for the result-kind rules. -- Continue to [Combinators](combinators.md) for the standard constructors over unions, records, and arrays. -- Continue to [Semantics and laws](semantics-and-laws.md) for the guarantees around identity preservation and no-op updates. +- Continue to [Composition](composition.md) for the result-kind matrix and the behaviour of read-only compositions. +- Continue to [Combinators](combinators.md) for the exact semantics of `guard`, `at`, `index`, and `each`. +- Continue to [API reference](api-reference.md) for the public signatures and exported utility types. +- Continue to [Semantics and laws](semantics-and-laws.md) for immutability, no-op rules, and round-trip expectations. diff --git a/docs/semantics-and-laws.md b/docs/semantics-and-laws.md index 07bfd3f..7b99d9b 100644 --- a/docs/semantics-and-laws.md +++ b/docs/semantics-and-laws.md @@ -1,35 +1,173 @@ # Semantics and laws -The library is designed around immutable updates and observable identity preservation where possible. -If mutation appears to work, that is merely impurity auditioning for a future bug report. +The library is built around immutable updates, explicit partiality, and reference preservation when a change can be proven to be no change at all. +If a mutation-based shortcut feels tempting, that is usually a sign you are trying to smuggle side effects through an optic-shaped hole. -## Lens laws +## Immutability -The test suite encodes the familiar lens expectations: +Writable optics return updated structures instead of mutating inputs. +This is exercised across the tests for `Lens`, `Prism`, `index`, composed optics, and traversals. -- get after set returns the written value -- set after get restores the original structure -- successive sets keep only the latest write +- `Lens#set` returns a new structure when the focus changes. +- `Prism#set` returns a new structure when the focus exists and changes, or when its concrete setter materializes a branch. +- `Traversal#modify` returns a new structure when any focused value changes. +- `Getter` and `Fold` are read-only by construction; they expose no `set` or `modify`. -## Operational semantics +## Value-or-updater semantics -- `Lens#set` and `Prism#set` accept either a concrete value or an updater function. -- Original inputs are never mutated. -- Updaters that do not change the focused value preserve the original reference when the library can prove it. -- `Prism#get` returns `undefined` when the focused branch is absent. -- Composed prism writes through an absent branch are no-ops by default. -- `Traversal#modify` applies a transformation to every focused element. +`Lens#set` and `Prism#set` both accept either: -## Important edge case +- a concrete replacement value +- an updater function `(current) => next` -`Prism` composed with `Iso` has one special materialization rule: +The two forms are not interchangeable in partial situations. -- concrete writes may construct the missing intermediate via the outer prism's setter -- updater writes remain no-ops when the branch is absent +### Concrete writes -This is the one place where total invertibility leaks enough structure to synthesize the missing middle. +Concrete writes can materialize when the optic itself knows how to construct the missing branch: -## Stable neighbors +- a bare `Prism` created with `Prism().of(...)` delegates to its own setter +- `guard` can replace a non-matching branch with a matching one +- `at` upserts a missing key +- `Prism ∘ Iso` can materialize because the `Iso` can reconstruct the missing intermediate value -- [Composition](composition.md) explains why certain pairs degrade to `Prism`, `Traversal`, or `Fold`. -- [Best practices](best-practices.md) turns these guarantees into caller guidance. +### Updater writes + +Updater writes require a current focused value. +If the optic cannot read a current value, the update is a no-op. + +This holds for: + +- `Prism().of(...)` when `get` returns `undefined` +- `guard` on a non-matching branch +- `at` on an absent key +- `index` when out of bounds +- composed partial paths where an outer branch is missing +- `Prism ∘ Iso` when the outer branch is missing + +## Absent-branch no-ops in composed paths + +The key operational rule for composition is that missing intermediate branches do not get fabricated by default. + +```ts +import { Lens, Prism, compose } from '@fuiste/optics' + +type Address = { city: string } +type Person = { address?: Address } + +const address = Prism().of({ + get: (person) => person.address, + set: (next) => (person) => ({ ...person, address: next }), +}) + +const city = compose(address, Lens
().prop('city')) + +city.get({}) // undefined +city.set('Paris')({}) // unchanged +city.set((name) => name.toUpperCase())({}) // unchanged +``` + +That behaviour is what keeps a composed `Prism` honest. Without it, partial optics would quietly become structure-synthesis machinery. + +## `Prism ∘ Iso` materialization + +There is one explicit exception to the general no-op rule. + +```ts +import { Iso, Prism, compose } from '@fuiste/optics' + +type Model = { count?: number } + +const count = Prism().of({ + get: (model) => model.count, + set: (next) => (model) => ({ ...model, count: next }), +}) + +const asString = Iso({ + to: (n) => `${n}`, + from: (s) => parseInt(s, 10), +}) + +const countText = compose(count, asString) + +countText.set('9')({}) // { count: 9 } +countText.set((value) => `${parseInt(value, 10) + 1}`)({}) // unchanged +``` + +The distinction is deliberate: + +- a concrete value can be pushed backward through the `Iso` via `from` +- an updater function still needs an existing focused value to run against + +## Traversal semantics + +`Traversal` is the writable many-focus optic. + +- `getAll` returns every focused value in order. +- `modify` applies the mapper to every focused value. +- If a traversal is composed through a missing outer `Prism`, `modify` is a no-op. +- The built-in `each()` traversal preserves the original array reference when no element changes. + +Representative example: + +```ts +import { each } from '@fuiste/optics' + +const nums = each() + +nums.getAll([1, 2, 3]) // [1, 2, 3] +nums.modify((n) => n * 2)([1, 2, 3]) // [2, 4, 6] +nums.modify((n) => n)([1, 2, 3]) // same array reference +``` + +## Identity preservation + +Where the implementation can prove that an update is unchanged, it returns the original source reference. +This is an observable and tested property, not an accidental optimization. + +Examples covered by the tests: + +- `Lens#set` with the existing value returns the original source. +- `Lens#set` with an identity updater returns the original source. +- `Prism#set` returns the original source when the branch is unchanged. +- `index(i)` returns the original array when the focused element is unchanged or out of bounds. +- `each().modify(identity)` returns the original array. +- composed `Lens` and composed `Prism` updates preserve identity when the focused value does not change. + +Do not assume the converse. A changed value may still force new outer structure, because immutability is doing its job. + +## Law-like expectations + +### Lens laws + +The test suite encodes the familiar lens laws: + +- get-set: `lens.set(lens.get(s))(s) === s` +- set-get: `lens.get(lens.set(a)(s)) === a` +- set-set: the latest write wins + +These are exercised on a composed lens, which is the interesting case because it verifies nested immutable updates rather than trivial projection. + +### Iso round-trips + +The test suite also asserts the usual isomorphism expectations: + +- `from(to(s)) === s` +- `to(from(a)) === a` + +Those equalities are only as honest as the functions you supply to `Iso`. An `Iso` whose `to` and `from` are not actual inverses is merely a pair of functions wearing a fake moustache. + +## Read-only outcomes + +When a composition yields `Getter` or `Fold`, mutation operations disappear from the API. + +- `Getter` has `get` and no `set` +- `Fold` has `getAll` and no `modify` + +This is both a semantic constraint and a practical one: the composed value literally lacks those methods. + +## Related pages + +- [Composition](composition.md) for the result-kind matrix behind these behaviours. +- [Combinators](combinators.md) for the standard partial and many-focus constructors. +- [Best practices](best-practices.md) for guidance on using these guarantees without turning your data model into performance theater.