@fuiste/optics
v1.0.1
Published
Type-safe, functional optics (lenses/prisms) for immutable data
Readme
Optics (Lens, Prism, Iso)
Type-safe, functional optics for immutable data: lenses for required data, prisms for optional/union data, and isomorphisms for total, invertible mappings.
Installation
# npm
npm install @fuiste/optics
# pnpm
pnpm add @fuiste/optics
# yarn
yarn add @fuiste/optics
# bun
bun add @fuiste/opticsWhat and why
- Lens: Focus on a required field; always gets a value and can set immutably
- Prism: Focus on an optional or union branch; get may return undefined
- Iso: Total, invertible mapping between two types
(to, from) - Composition: You can compose any combination of lens, prism, and iso
- Lens ∘ Lens => Lens
- Lens ∘ Prism => Prism
- Lens ∘ Iso => Lens
- Prism ∘ Lens => Prism
- Prism ∘ Prism => Prism
- Prism ∘ Iso => Prism
- Iso ∘ Lens => Lens
- Iso ∘ Prism => Prism
- Iso ∘ Iso => Iso
Core principles:
- Pure and immutable:
setreturns a new object; originals are never mutated - Type-safe: illegal paths/types are rejected at compile time
- Ergonomic:
setaccepts either a value or an updater function(a) => afor both Lens and Prism
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: { ... } }
// Functional updates without intermediate variables
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 works the same as Lens
addressPrism.set((addr) => ({ ...addr, city: 'LA' }))({
name: 'A',
address: { street: '1', city: 'NYC' },
})
// => { name: 'A', address: { street: '1', city: 'LA' } }Composition
import { Lens, Prism, Iso } 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 = Prism<Person>().compose(addressPrism, cityLens)
cityPrism.get({ name: 'A', address: { street: '1', city: 'NYC' } }) // 'NYC'
cityPrism.get({ name: 'A' }) // undefined
// Setting through a missing path is a no-op for composed prisms
const updated = cityPrism.set('LA')({ name: 'A' }) // unchanged when address is undefined
// Function updaters also work
cityPrism.set((city) => city.toUpperCase())({ name: 'A', address: { street: '1', city: 'nyc' } })
// => city becomes 'NYC'
// Lens ∘ Iso => Lens (representing as string)
const numberString = Iso<number, string>({ to: (n) => `${n}`, from: (s) => parseInt(s, 10) })
type Model = { count: number }
const countLens = Lens<Model>().prop('count')
const countAsString = Lens<Model>().compose(countLens, numberString)
countAsString.get({ count: 7 }) // '7'
countAsString.set('10')({ count: 7 }) // { count: 10 }
// Prism ∘ Iso => Prism (materializes on concrete values)
type MaybeCount = { count?: number }
const countPrism = Prism<MaybeCount>().of({
get: (m) => m.count,
set: (count) => (m) => ({ ...m, count }),
})
const countAsStringPrism = Prism<MaybeCount>().compose(countPrism, numberString)
countAsStringPrism.get({}) // undefined
countAsStringPrism.set('9')({}) // { count: 9 } // concrete values materializeArrays
type Company = { name: string; employees: Array<{ name: string; role: string }> }
const employeesLens = Lens<Company>().prop('employees')
const firstEmployeeLens = Lens<Company>().compose(
employeesLens,
Lens<Company['employees']>().prop(0),
)
const company: Company = {
name: 'Acme',
employees: [
{ name: 'John', role: 'Developer' },
{ name: 'Jane', role: 'Manager' },
],
}
firstEmployeeLens.get(company) // { name: 'John', role: 'Developer' }
firstEmployeeLens.set({ name: 'Bob', role: 'Designer' })(company)
// => updates index 0 immutablyUnion types with prisms
type Circle = { type: 'circle'; radius: number }
type Square = { type: 'square'; side: number }
type Shape = Circle | Square
const circlePrism = Prism<Shape>().of({
get: (s): Circle | undefined => (s.type === 'circle' ? s : undefined),
set: (circle) => (_) => circle,
})
const radiusLens = Lens<Circle>().prop('radius')
const circleRadius = Prism<Shape>().compose(circlePrism, radiusLens)
circleRadius.get({ type: 'circle', radius: 5 }) // 5
circleRadius.set(7)({ type: 'circle', radius: 5 }) // { type: 'circle', radius: 7 }
// Function updater on composed prism
circleRadius.set((r) => r + 1)({ type: 'circle', radius: 6 }) // { type: 'circle', radius: 7 }Practical: deeply optional configuration
type Configuration = {
search?: {
options?: { isPrefillEnabled?: boolean }
}
}
const searchPrism = Prism<Configuration>().of({
get: (c) => c.search,
set: (search) => (c) => ({ ...c, search }),
})
const optionsPrism = Prism<NonNullable<Configuration['search']>>().of({
get: (s) => s.options,
set: (options) => (s) => ({ ...s, options }),
})
const isPrefillEnabledPrism = Prism<
NonNullable<NonNullable<Configuration['search']>['options']>
>().of({
get: (o) => o.isPrefillEnabled,
set: (isPrefillEnabled) => (o) => ({ ...o, isPrefillEnabled }),
})
const partialComposed = Prism<Configuration>().compose(searchPrism, optionsPrism)
const composed = Prism<Configuration>().compose(partialComposed, isPrefillEnabledPrism)
composed.get({}) // undefined
composed.set(true)({}) // unchanged (missing branches)
// Function setter is also a no-op when branches are missing
composed.set((v) => !v)({}) // unchangedBest practices
- Prefer composition of small optics over writing one big custom getter/setter
- Use functional setters for derived updates, e.g.
set((a) => f(a)) - Treat optics as pure: never mutate inputs inside
set - For arrays, use numeric keys with
prop(index)and compose - For optional/union data, push creation logic into the outermost
Prism#of({ set })if you want to materialize missing branches. By design, setting through a composed prism where any outer branch is missing is a no-op - Use TypeScript helpers like
NonNullable<T>andExclude<T, undefined>to narrow optional shapes when building intermediate prisms
API reference
Factories
// Lens factory for a source type S
Lens<S>()
.prop<K extends keyof S>(key: K): Lens<S, S[K]>
.compose<A, B>(outer: Lens<S, A>, inner: Lens<A, B> | Prism<A, B> | Iso<A, B>): Lens<S, B> | Prism<S, B>
// Prism factory for a source type S
Prism<S>()
.of<A>({ get: (s: S) => A | undefined; set: (a: A | ((a: A) => A)) => <T extends S>(s: T) => T }): Prism<S, A>
.compose<A, B>(outer: Prism<S, A>, inner: Lens<A, B> | Prism<A, B> | Iso<A, B>): Prism<S, B>
// Iso constructor
Iso<S, A>({ to: (s: S) => A, from: (a: A) => S }): Iso<S, A>Interfaces
// A functional lens focusing a required value A inside source S
export type Lens<S, A> = {
_tag: 'lens'
get: (s: S) => A
// Accepts either a value or an updater function
set: (a: A | ((a: A) => A)) => <T extends S>(s: T) => T
}
// A functional prism focusing an optional/union value A inside source S
export type Prism<S, A> = {
_tag: 'prism'
get: (s: S) => A | undefined
set: (a: A | ((a: A) => A)) => <T extends S>(s: T) => T
}
// A total, invertible mapping between S and A
export type Iso<S, A> = {
_tag: 'iso'
to: (s: S) => A
from: (a: A) => S
}Notes:
Lens#setandPrism#setboth accept a value or function and return a new object of the same structural type as the input. Unchanged branches are preservedPrism#getmay returnundefined. When using composed prisms, any missing outer branch results inundefinedPrism#seton a composed path that is currently missing is a no-op by default. If you want to create missing branches, do it in the outer prism’sset. An exception is when composing withIso: providing a concrete value will be materialized via the outerPrism#set, while providing a function remains a no-op if missing
Utility types
// Extract source/target types from optics
InferLensSource<L extends Lens<any, any>>
InferLensTarget<L extends Lens<any, any>>
InferPrismSource<P extends Prism<any, any>>
InferPrismTarget<P extends Prism<any, any>>
InferIsoSource<I extends Iso<any, any>>
InferIsoTarget<I extends Iso<any, any>>Examples:
const nameLens = Lens<Person>().prop('name')
type PersonFromLens = InferLensSource<typeof nameLens> // Person
type Name = InferLensTarget<typeof nameLens> // string
const addressPrism = Prism<Person>().of({
get: (p) => p.address,
set: (a) => (p) => ({ ...p, address: a }),
})
type PersonFromPrism = InferPrismSource<typeof addressPrism> // Person
type Address = InferPrismTarget<typeof addressPrism> // { street: string; city: string }Examples from the test suite
Composed lenses (deep required updates)
type Address = { street: string; city: string }
type Person = { name: string; address: Address }
const addressLens = Lens<Person>().prop('address')
const cityLens = Lens<Address>().prop('city')
const personCityLens = Lens<Person>().compose(addressLens, cityLens)
personCityLens.get({ name: 'John', address: { street: '123 Main', city: 'New York' } }) // 'New York'
personCityLens.set('Los Angeles')({
name: 'John',
address: { street: '123 Main', city: 'New York' },
})
// => updates city immutablyPrism ∘ Lens (optional then required)
type Address = { street: string; city: string }
type Person = { name: string; age: number; address?: Address }
const addressPrism = Prism<Person>().of({
get: (p) => p.address,
set: (address) => (p) => ({ ...p, address }),
})
const cityLens = Lens<Address>().prop('city')
const composed = Prism<Person>().compose(addressPrism, cityLens)
composed.get({ name: 'John', age: 30, address: { street: '123', city: 'New York' } }) // 'New York'
composed.set('Los Angeles')({ name: 'John', age: 30, address: { street: '123', city: 'New York' } })
// => address.city becomes 'Los Angeles'
// Function form
composed.set((city) => city.toUpperCase())({
name: 'John',
age: 30,
address: { street: '123', city: 'nyc' },
})
// => address.city becomes 'NYC'Lens ∘ Prism (required then optional)
type Address = { street: string; city: string }
type Person = { name: string; age: number; address: Address }
const addressLens = Lens<Person>().prop('address')
const cityPrism = Prism<Address>().of({
get: (a) => a.city,
set: (city) => (a) => ({ ...a, city }),
})
const composed = Lens<Person>().compose(addressLens, cityPrism)
composed.get({ name: 'John', age: 30, address: { street: '123', city: 'New York' } }) // 'New York'Prism ∘ Prism (deeply optional)
type Address = { street: string; city: string }
type Person = { name: string; age: number; address?: Address }
const addressPrism = Prism<Person>().of({
get: (p) => p.address,
set: (a) => (p) => ({ ...p, address: a }),
})
const cityPrism = Prism<Address>().of({
get: (a) => a.city,
set: (city) => (a) => ({ ...a, city }),
})
const composed = Prism<Person>().compose(addressPrism, cityPrism)
composed.get({ name: 'John', age: 30, address: { street: '123', city: 'New York' } }) // 'New York'
composed.get({ name: 'John', age: 30 }) // undefined
composed.set('Los Angeles')({ name: 'John', age: 30 }) // unchanged (no address)
// Function setter is also a no-op when a branch is missing
composed.set((city) => city.toUpperCase())({ name: 'John', age: 30 }) // unchangedComplex nested optionals (first department manager)
type Company = {
name: string
departments?: Array<{
name: string
manager?: { name: string; email: string }
}>
}
const firstDepartmentPrism = Prism<Company>().of({
get: (c) => c.departments?.[0],
set: (dept) => (c) => ({
...c,
departments: c.departments ? [dept, ...c.departments.slice(1)] : [dept],
}),
})
const managerPrism = Prism<Exclude<Company['departments'], undefined>[number]>().of({
get: (dept) => dept.manager,
set: (manager) => (dept) => ({ ...dept, manager }),
})
const composed = Prism<Company>().compose(firstDepartmentPrism, managerPrism)
composed.get({
name: 'Acme',
departments: [{ name: 'Eng', manager: { name: 'John', email: '[email protected]' } }],
})
// => { name: 'John', email: '[email protected]' }Tips and gotchas
- Composed prisms are safe-by-default: missing outer values mean
getreturnsundefinedandsetis a no-op - If you want
setto create missing structure, do it at the nearest prism with asetthat materializes the branch - Arrays are first-class: numeric
propkeys are supported and type-checked - Share interfaces across lenses: you can make a
Lens<Interface>()and use it safely wherever the interface applies
