@fuiste/optics
v2.0.0
Published
Type-safe, functional optics (lenses, prisms, traversals, and more) for immutable data
Readme
Optics
Type-safe, functional optics for immutable data in TypeScript.
- Lens — total focus on a required value
- Prism — partial focus on an optional or union value
- Iso — total, invertible mapping between two types
- Traversal — focus on zero or more values (e.g. all elements of an array)
- Getter — read-only total focus (computed/derived values)
- Fold — read-only multi-focus (extract without modify)
All optics compose freely via a standalone compose function. Four ergonomic combinators — guard, at, index, and each — cover the most common construction patterns.
Installation
# npm
npm install @fuiste/optics
# pnpm
pnpm add @fuiste/optics
# yarn
yarn add @fuiste/optics
# bun
bun add @fuiste/opticsDevelopment
- Package manager:
pnpm - Supported Node.js:
>=20.19.0
Quick start
Lens (required data)
import { Lens } from '@fuiste/optics'
type Person = {
name: string
age: number
address: { street: string; city: string }
}
const nameLens = Lens<Person>().prop('name')
const person: Person = { name: 'John', age: 30, address: { street: '123', city: 'NYC' } }
nameLens.get(person) // 'John'
nameLens.set('Jane')(person) // { name: 'Jane', age: 30, address: { ... } }
nameLens.set((name) => name.toUpperCase())(person) // name == 'JOHN'Prism (optional data)
import { Prism } from '@fuiste/optics'
type Person = {
name: string
address?: { street: string; city: string }
}
const addressPrism = Prism<Person>().of({
get: (p) => p.address,
set: (address) => (p) => ({ ...p, address }),
})
addressPrism.get({ name: 'A' }) // undefined
addressPrism.set({ street: '456', city: 'LA' })({ name: 'A' })
// => { name: 'A', address: { street: '456', city: 'LA' } }
// Functional updater — no-op when absent
addressPrism.set((addr) => ({ ...addr, city: 'LA' }))({
name: 'A',
address: { street: '1', city: 'NYC' },
})
// => { name: 'A', address: { street: '1', city: 'LA' } }Iso (invertible mapping)
import { Iso } from '@fuiste/optics'
const numberString = Iso<number, string>({ to: (n) => `${n}`, from: (s) => parseInt(s, 10) })
numberString.to(42) // '42'
numberString.from('7') // 7Traversal (multiple values)
import { Lens, compose, each } from '@fuiste/optics'
type Team = { members: string[] }
const membersLens = Lens<Team>().prop('members')
const eachMember = each<string>()
const allMembers = compose(membersLens, eachMember)
const team: Team = { members: ['Alice', 'Bob'] }
allMembers.getAll(team) // ['Alice', 'Bob']
allMembers.modify((n) => n.toUpperCase())(team)
// => { members: ['ALICE', 'BOB'] }Getter (computed read-only value)
import { Lens, Getter, compose } from '@fuiste/optics'
type Person = { firstName: string; lastName: string }
type Team = { lead: Person }
const fullName = Getter<Person, string>((p) => `${p.firstName} ${p.lastName}`)
const leadLens = Lens<Team>().prop('lead')
const leadName = compose(leadLens, fullName)
leadName.get({ lead: { firstName: 'Alice', lastName: 'Smith' } }) // 'Alice Smith'
// leadName has no `set` — it's read-onlyFold (read-only extraction of many values)
import { Fold } from '@fuiste/optics'
const words = Fold<string, string>((s) => s.split(' '))
words.getAll('hello world') // ['hello', 'world']Composition
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 |
Rules of thumb:
- Anything with Fold → Fold (read-only is contagious)
- Getter + partial optic → Fold; Getter + total optic → Getter
- Traversal absorbs other writable optics → Traversal
- Iso is transparent: the other optic's kind wins
- Lens ∘ Lens → Lens; everything else with Prism → Prism
import { Lens, Prism, Iso, compose } from '@fuiste/optics'
type Address = { street: string; city: string }
type Person = { name: string; address?: Address }
const addressPrism = Prism<Person>().of({
get: (p) => p.address,
set: (address) => (p) => ({ ...p, address }),
})
const cityLens = Lens<Address>().prop('city')
// Prism ∘ Lens => Prism
const cityPrism = compose(addressPrism, cityLens)
cityPrism.get({ name: 'A', address: { street: '1', city: 'NYC' } }) // 'NYC'
cityPrism.get({ name: 'A' }) // undefined
cityPrism.set('LA')({ name: 'A' }) // unchanged (missing path is a no-op)
// Lens ∘ Iso => Lens
type Model = { count: number }
const countLens = Lens<Model>().prop('count')
const numberString = Iso<number, string>({ to: (n) => `${n}`, from: (s) => parseInt(s, 10) })
const countAsString = compose(countLens, numberString)
countAsString.get({ count: 7 }) // '7'
countAsString.set('10')({ count: 7 }) // { count: 10 }Multi-step chains
Compose can be nested for deep paths:
import { Lens, compose, each } from '@fuiste/optics'
type Company = { employees: Array<{ name: string; role: string }> }
const employeesLens = Lens<Company>().prop('employees')
const eachEmployee = each<{ name: string; role: string }>()
const empName = Lens<{ name: string; role: string }>().prop('name')
const allEmployeeNames = compose(compose(employeesLens, eachEmployee), empName)
allEmployeeNames.getAll(company) // ['Alice', 'Bob', ...]
allEmployeeNames.modify((n) => n.toUpperCase())(company)Combinators
guard — type-guard prism
Creates a prism from a TypeScript type guard. Much more ergonomic than manually writing Prism().of(...) for discriminated unions.
import { guard, Lens, compose } from '@fuiste/optics'
type Circle = { type: 'circle'; radius: number }
type Square = { type: 'square'; side: number }
type Shape = Circle | Square
const circlePrism = guard<Shape, Circle>((s): s is Circle => s.type === 'circle')
circlePrism.get({ type: 'circle', radius: 5 }) // { type: 'circle', radius: 5 }
circlePrism.get({ type: 'square', side: 4 }) // undefined
// Compose with a lens
const circleRadius = compose(circlePrism, Lens<Circle>().prop('radius'))
circleRadius.get({ type: 'circle', radius: 5 }) // 5
circleRadius.set(10)({ type: 'circle', radius: 5 }) // { type: 'circle', radius: 10 }at — record key access
Creates a prism that focuses on a key in a Record<string, V>. Returns undefined when the key is absent; sets/upserts when called.
import { at, Lens, compose } from '@fuiste/optics'
type Config = { headers: Record<string, string> }
const headersLens = Lens<Config>().prop('headers')
const authHeader = at<string>('Authorization')
const configAuth = compose(headersLens, authHeader)
configAuth.get({ headers: { Authorization: 'Bearer x' } }) // 'Bearer x'
configAuth.get({ headers: {} }) // undefined
configAuth.set('Bearer y')({ headers: {} })
// => { headers: { Authorization: 'Bearer y' } }index — array element access
Creates a prism that focuses on a single array element. get returns undefined when the index is out of bounds, and set is a no-op when the element is missing.
import { index } from '@fuiste/optics'
const second = index<number>(1)
second.get([10, 20, 30]) // 20
second.get([10]) // undefined
second.set(99)([10, 20, 30]) // [10, 99, 30]
second.set(99)([10]) // unchangedeach — array traversal
Creates a traversal over all elements of a ReadonlyArray<A>.
import { each, compose, Lens } from '@fuiste/optics'
const nums = each<number>()
nums.getAll([1, 2, 3]) // [1, 2, 3]
nums.modify((n) => n * 2)([1, 2, 3]) // [2, 4, 6]API reference
Types
type Lens<S, A> = {
_tag: 'lens'
get: (s: S) => A
set: (a: A | ((a: A) => A)) => (s: S) => S
}
type Prism<S, A> = {
_tag: 'prism'
get: (s: S) => A | undefined
set: (a: A | ((a: A) => A)) => (s: S) => S
}
type Iso<S, A> = {
_tag: 'iso'
to: (s: S) => A
from: (a: A) => S
}
type Traversal<S, A> = {
_tag: 'traversal'
getAll: (s: S) => ReadonlyArray<A>
modify: (f: (a: A) => A) => (s: S) => S
}
type Getter<S, A> = {
_tag: 'getter'
get: (s: S) => A
}
type Fold<S, A> = {
_tag: 'fold'
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>Factories
// Lens factory — use .prop to focus on a property key
Lens<S>().prop<K extends keyof S>(key: K): Lens<S, S[K]>
// Prism factory — use .of to build from get/set
Prism<S>().of<A>({ get, set }): Prism<S, A>
// Direct constructors
Iso<S, A>({ to, from }): Iso<S, A>
Traversal<S, A>({ getAll, modify }): Traversal<S, A>
Getter<S, A>(get: (s: S) => A): Getter<S, A>
Fold<S, A>(getAll: (s: S) => ReadonlyArray<A>): Fold<S, A>Standalone functions
// Universal composition — 36 overloads, result type inferred from inputs
compose<S, A, B>(outer: Optic<S, A>, inner: Optic<A, B>): Optic<S, B>
// Type-guard prism
guard<S, A extends S>(predicate: (s: S) => s is A): Prism<S, A>
// Record key prism
at<V>(key: string): Prism<Record<string, V>, V>
// Array index prism
index<A>(idx: number): Prism<ReadonlyArray<A>, A>
// Array element traversal
each<A>(): Traversal<ReadonlyArray<A>, A>Utility types
InferSource<O extends Optic> // Extract the S from any optic
InferTarget<O extends Optic> // Extract the A from any opticBehaviour notes
Lens#setandPrism#setboth accept a value or(a) => aupdater function and return a new object. Originals are never mutated.- Writable optics preserve the caller's source shape in their public setter signatures for compatibility with narrowed states.
Prism#getmay returnundefined. In composed prisms, any missing outer branch results inundefined.Prism#setthrough a composed path where an outer branch is missing is a no-op by default. Function updaters are also no-ops when missing.- Exception — Prism ∘ Iso: providing a concrete value materializes via the outer Prism's
seteven whengetreturnsundefined, because the Iso can always construct the intermediate value. Function updaters remain a no-op when missing. Traversal#modifyapplies the function to every focused element. For composed traversals through a missing prism branch, modify is a no-op.- Unchanged updates preserve reference identity whenever the library can detect that the focused value did not change.
GetterandFoldare read-only — they have nosetormodify. Composing any optic with a read-only optic produces a read-only result.
Best practices
- Prefer composition of small optics over one big custom getter/setter
- Use
guardfor discriminated unions instead of manualPrism().of - Use
each+composefor bulk array operations - Use
index()for a single array element andat()for record/map keys - Use
Getterfor derived values that shouldn't be settable - Treat optics as pure: never mutate inputs inside
set - For arrays, prefer
each()for all elements orindex(i)for one element.Lens<T[]>().prop(index)remains supported for compatibility.
