Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:**

Expand Down Expand Up @@ -319,7 +324,13 @@ type Fold<S, A> = {
getAll: (s: S) => ReadonlyArray<A>
}

type Optic<S, A> = Lens<S, A> | Prism<S, A> | Iso<S, A> | Traversal<S, A> | Getter<S, A> | Fold<S, A>
type Optic<S, A> =
| Lens<S, A>
| Prism<S, A>
| Iso<S, A>
| Traversal<S, A>
| Getter<S, A>
| Fold<S, A>
```

### Factories
Expand Down
37 changes: 37 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -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 |
42 changes: 42 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
@@ -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<S, A>`
- `Prism<S, A>`
- `Iso<S, A>`
- `Traversal<S, A>`
- `Getter<S, A>`
- `Fold<S, A>`
- `Optic<S, A>`

## Constructors and factories

- `Lens<S>().prop(key)` creates a total property focus.
- `Prism<S>().of({ get, set })` creates a partial focus.
- `Iso<S, A>({ to, from })` creates an invertible mapping.
- `Traversal<S, A>({ getAll, modify })` creates a writable multi-focus.
- `Getter<S, A>(get)` creates a read-only total focus.
- `Fold<S, A>(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<O>`
- `InferTarget<O>`

## 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.
31 changes: 31 additions & 0 deletions docs/best-practices.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 47 additions & 0 deletions docs/combinators.md
Original file line number Diff line number Diff line change
@@ -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, Circle>((shape): shape is Circle => shape.type === 'circle')
const radius = compose(circle, Lens<Circle>().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.
53 changes: 53 additions & 0 deletions docs/composition.md
Original file line number Diff line number Diff line change
@@ -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<Person>().of({
get: (person) => person.address,
set: (next) => (person) => ({ ...person, address: next }),
})

const city = Lens<Address>().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.
39 changes: 39 additions & 0 deletions docs/navigation.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
38 changes: 38 additions & 0 deletions docs/quick-start.md
Original file line number Diff line number Diff line change
@@ -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<Profile>().prop('user')
const nameLens = Lens<Profile['user']>().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.
35 changes: 35 additions & 0 deletions docs/semantics-and-laws.md
Original file line number Diff line number Diff line change
@@ -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.