optics
bun add @stopcock/opticsRead and update deeply nested immutable data without spread hell.
Four kinds of optic, each for a different shape of access: Lens for a single value on an object, Prism for a value that might not be there (like Option or Result), Traversal for multiple values at once (every item in an array, or a filtered subset), and Iso for lossless conversion between two representations.
import { prop, view, set, over, composeLens } from '@stopcock/optics'
const name = prop<User>()('name')const city = composeLens(prop<User>()('address'), prop<Address>()('city'))
view(name, user) // "alice"set(city, 'London', user) // new user with city = 'London'over(name, s => s.toUpperCase(), user)Real-world patterns
Section titled “Real-world patterns”Updating nested state without spread hell
Section titled “Updating nested state without spread hell”// Manual spread: gets worse with every level of nestingconst updated = { ...state, user: { ...state.user, address: { ...state.user.address, city: 'London', }, },}
// Optics: one line, any depthimport { path, set } from '@stopcock/optics'const city = path<State, 'user', 'address', 'city'>('user', 'address', 'city')const updated = set(city, 'London', state)Updating all items matching a condition
Section titled “Updating all items matching a condition”import { each, filtered, composeTraversal, modify } from '@stopcock/optics'
const expiredItems = composeTraversal( each<CartItem>(), filtered(item => item.expiresAt < Date.now()),)
const cart = modify(expiredItems, item => ({ ...item, status: 'expired' }), state.items)Optional chaining with Prism
Section titled “Optional chaining with Prism”import { some, preview, composePrism } from '@stopcock/optics'
const maybeDiscount = composePrism(some<User>(), some<number>())// Safely access user?.discount, returns Option<number>const discount = preview(maybeDiscount, currentUser)Focus on a single value in a product type.
type Lens<S, A> = { get: (s: S) => A; set: (a: A, s: S) => S }
lens<S, A>(get: (s: S) => A, set: (a: A, s: S) => S): Lens<S, A>prop<S>(): <K extends keyof S>(key: K) => Lens<S, S[K]>index<A>(i: number): Lens<A[], A>path<S, K1 extends keyof S>(k1: K1): Lens<S, S[K1]>view<S, A>(lens: Lens<S, A>, s: S): Aset<S, A>(lens: Lens<S, A>, a: A, s: S): Sover<S, A>(lens: Lens<S, A>, f: (a: A) => A, s: S): ScomposeLens<S, A, B>(outer: Lens<S, A>, inner: Lens<A, B>): Lens<S, B>Focus on a value that may not exist (sum types, optionals).
type Prism<S, A> = { preview: (s: S) => A | undefined; set: (a: A, s: S) => S }
prism<S, A>(preview: (s: S) => A | undefined, set: (a: A, s: S) => S): Prism<S, A>fromPredicate<A>(pred: (a: A) => boolean): Prism<A, A>some<A>(): Prism<A | undefined, A>ok<A, E>(): Prism<Result<A, E>, A>preview<S, A>(prism: Prism<S, A>, s: S): A | undefinedcomposePrism<S, A, B>(outer: Prism<S, A>, inner: Prism<A, B>): Prism<S, B>Traversal
Section titled “Traversal”Focus on multiple values at once.
type Traversal<S, A> = { toArray: (s: S) => A[]; modify: (f: (a: A) => A, s: S) => S }
traversal<S, A>(toArray: (s: S) => A[], modify: (f: (a: A) => A, s: S) => S): Traversal<S, A>each<A>(): Traversal<A[], A>filtered<A>(pred: (a: A) => boolean): Traversal<A[], A>composeTraversal<S, A, B>(outer: Traversal<S, A>, inner: Traversal<A, B>): Traversal<S, B>toArray<S, A>(traversal: Traversal<S, A>, s: S): A[]modify<S, A>(traversal: Traversal<S, A>, f: (a: A) => A, s: S): SLossless bidirectional transformation.
type Iso<S, A> = { get: (s: S) => A; reverseGet: (a: A) => S }
iso<S, A>(get: (s: S) => A, reverseGet: (a: A) => S): Iso<S, A>reverse<S, A>(iso: Iso<S, A>): Iso<A, S>composeIso<S, A, B>(outer: Iso<S, A>, inner: Iso<A, B>): Iso<S, B>Cross-optic composition
Section titled “Cross-optic composition”composeOptics(outer, inner): Optic