diff --git a/README.md b/README.md index bf8dc42..29e1394 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,11 @@ bun add @fuiste/optics - Package manager: `pnpm` - Supported Node.js: `>=20.19.0` +## Documentation + +`README.md` remains the primary installation and first-run surface. +Deeper reference material lives in [`docs/README.md`](docs/README.md), which is the canonical docs home consumed by both the repository and the future Pages wrapper. + ## Quick start ### Lens (required data) @@ -142,14 +147,14 @@ words.getAll('hello world') // ['hello', 'world'] All optics compose via the standalone `compose(outer, inner)` function. The return type is determined automatically: -| outer ∖ inner | **Lens** | **Prism** | **Iso** | **Traversal** | **Getter** | **Fold** | -| ------------- | -------- | --------- | ------- | ------------- | ---------- | -------- | -| **Lens** | Lens | Prism | Lens | Traversal | Getter | Fold | -| **Prism** | Prism | Prism | Prism | Traversal | Fold | Fold | -| **Iso** | Lens | Prism | Iso | Traversal | Getter | Fold | -| **Traversal** | Traversal| Traversal | Traversal| Traversal | Fold | Fold | -| **Getter** | Getter | Fold | Getter | Fold | Getter | Fold | -| **Fold** | Fold | Fold | Fold | Fold | Fold | Fold | +| outer ∖ inner | **Lens** | **Prism** | **Iso** | **Traversal** | **Getter** | **Fold** | +| ------------- | --------- | --------- | --------- | ------------- | ---------- | -------- | +| **Lens** | Lens | Prism | Lens | Traversal | Getter | Fold | +| **Prism** | Prism | Prism | Prism | Traversal | Fold | Fold | +| **Iso** | Lens | Prism | Iso | Traversal | Getter | Fold | +| **Traversal** | Traversal | Traversal | Traversal | Traversal | Fold | Fold | +| **Getter** | Getter | Fold | Getter | Fold | Getter | Fold | +| **Fold** | Fold | Fold | Fold | Fold | Fold | Fold | **Rules of thumb:** @@ -319,7 +324,13 @@ type Fold = { getAll: (s: S) => ReadonlyArray } -type Optic = Lens | Prism | Iso | Traversal | Getter | Fold +type Optic = + | Lens + | Prism + | Iso + | Traversal + | Getter + | Fold ``` ### Factories diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..0f1cb59 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,37 @@ +# Optics documentation + +This directory is the single source of truth for long-form documentation. +The same Markdown files should serve both of these consumers: + +- the in-repository docs experience +- the eventual GitHub Pages wrapper + +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. + +## 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 + +## Content 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 | diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..9340f81 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,42 @@ +# 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. + +## Exported optic types + +- `Lens` +- `Prism` +- `Iso` +- `Traversal` +- `Getter` +- `Fold` +- `Optic` + +## 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. + +## 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. + +## Utility types + +- `InferSource` +- `InferTarget` + +## Documentation split + +- [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. diff --git a/docs/best-practices.md b/docs/best-practices.md new file mode 100644 index 0000000..7078f5a --- /dev/null +++ b/docs/best-practices.md @@ -0,0 +1,31 @@ +# Best practices + +Use optics as small composable values, not as an excuse to hide arbitrary business logic behind a setter-shaped curtain. + +## Prefer small optics + +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. + +## Match the constructor to the shape + +- 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. + +## Reach for combinators first + +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. + +## Preserve purity + +- Do not mutate inside `set`, `modify`, or custom constructors. +- Treat updater functions as pure transformations. +- Rely on no-op behaviour for absent partial paths instead of smuggling in sentinel defaults. + +## Stable neighbors + +- [Combinators](combinators.md) covers the standard constructors. +- [Semantics and laws](semantics-and-laws.md) explains the behavioural guarantees these practices rely on. diff --git a/docs/combinators.md b/docs/combinators.md new file mode 100644 index 0000000..49bb666 --- /dev/null +++ b/docs/combinators.md @@ -0,0 +1,47 @@ +# 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. + +## `guard` + +`guard` lifts a TypeScript type guard into a `Prism`. +Use it for discriminated unions instead of hand-rolled `Prism().of(...)` definitions. + +```ts +import { Lens, compose, guard } from '@fuiste/optics' + +type Circle = { type: 'circle'; radius: number } +type Square = { type: 'square'; side: number } +type Shape = Circle | Square + +const circle = guard((shape): shape is Circle => shape.type === 'circle') +const radius = compose(circle, Lens().prop('radius')) +``` + +## `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. + +## `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. + +## `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. + +## Choosing the right combinator + +- 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. + +## Stable neighbors + +- [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. diff --git a/docs/composition.md b/docs/composition.md new file mode 100644 index 0000000..63c0388 --- /dev/null +++ b/docs/composition.md @@ -0,0 +1,53 @@ +# 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. + +## One operator, many result kinds + +All exported optics compose through `compose(outer, inner)`. +The result kind is determined by the participating optics. + +| outer \\ inner | `Lens` | `Prism` | `Iso` | `Traversal` | `Getter` | `Fold` | +| -------------- | ----------- | ----------- | ----------- | ----------- | -------- | ------ | +| `Lens` | `Lens` | `Prism` | `Lens` | `Traversal` | `Getter` | `Fold` | +| `Prism` | `Prism` | `Prism` | `Prism` | `Traversal` | `Fold` | `Fold` | +| `Iso` | `Lens` | `Prism` | `Iso` | `Traversal` | `Getter` | `Fold` | +| `Traversal` | `Traversal` | `Traversal` | `Traversal` | `Traversal` | `Fold` | `Fold` | +| `Getter` | `Getter` | `Fold` | `Getter` | `Fold` | `Getter` | `Fold` | +| `Fold` | `Fold` | `Fold` | `Fold` | `Fold` | `Fold` | `Fold` | + +## Rules of thumb + +- `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`. + +## Example + +```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 = Lens
().prop('city') +const personCity = compose(address, city) + +personCity.get({ address: { city: 'London' } }) // 'London' +personCity.get({}) // undefined +personCity.set('Paris')({}) // unchanged +``` + +## Stable neighbors + +- [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. diff --git a/docs/navigation.json b/docs/navigation.json new file mode 100644 index 0000000..8c0ea92 --- /dev/null +++ b/docs/navigation.json @@ -0,0 +1,39 @@ +{ + "formatVersion": 1, + "home": { + "title": "Optics documentation", + "path": "README.md" + }, + "pages": [ + { + "id": "quick-start", + "title": "Quick start", + "path": "quick-start.md" + }, + { + "id": "composition", + "title": "Composition", + "path": "composition.md" + }, + { + "id": "combinators", + "title": "Combinators", + "path": "combinators.md" + }, + { + "id": "api-reference", + "title": "API reference", + "path": "api-reference.md" + }, + { + "id": "semantics-and-laws", + "title": "Semantics and laws", + "path": "semantics-and-laws.md" + }, + { + "id": "best-practices", + "title": "Best practices", + "path": "best-practices.md" + } + ] +} diff --git a/docs/quick-start.md b/docs/quick-start.md new file mode 100644 index 0000000..c3146ee --- /dev/null +++ b/docs/quick-start.md @@ -0,0 +1,38 @@ +# 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. + +## Minimal progression + +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. + +## A first composition + +```ts +import { Lens, compose } from '@fuiste/optics' + +type Profile = { + user: { + name: string + } +} + +const userLens = Lens().prop('user') +const nameLens = Lens().prop('name') +const profileName = compose(userLens, nameLens) + +const profile: Profile = { user: { name: 'Ada' } } + +profileName.get(profile) // 'Ada' +profileName.set('Grace')(profile) // { user: { name: 'Grace' } } +``` + +## 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. diff --git a/docs/semantics-and-laws.md b/docs/semantics-and-laws.md new file mode 100644 index 0000000..07bfd3f --- /dev/null +++ b/docs/semantics-and-laws.md @@ -0,0 +1,35 @@ +# 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. + +## Lens laws + +The test suite encodes the familiar lens expectations: + +- get after set returns the written value +- set after get restores the original structure +- successive sets keep only the latest write + +## Operational 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. + +## Important edge case + +`Prism` composed with `Iso` has one special materialization rule: + +- concrete writes may construct the missing intermediate via the outer prism's setter +- updater writes remain no-ops when the branch is absent + +This is the one place where total invertibility leaks enough structure to synthesize the missing middle. + +## Stable neighbors + +- [Composition](composition.md) explains why certain pairs degrade to `Prism`, `Traversal`, or `Fold`. +- [Best practices](best-practices.md) turns these guarantees into caller guidance.