Type-safe, functional optics (lenses/prisms) for immutable data
npm install @fuiste/opticsType-safe, functional optics for immutable data: lenses for required data, prisms for optional/union data, and isomorphisms for total, invertible mappings.
``bashnpm
npm install @fuiste/optics
What 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 => IsoCore principles:
- Pure and immutable:
set returns a new object; originals are never mutated
- Type-safe: illegal paths/types are rejected at compile time
- Ergonomic: set accepts either a value or an updater function (a) => a for both Lens and Prism---
Quick start
$3
`typescript
import { Lens } from '@fuiste/optics'type Person = {
name: string
age: number
address: { street: string; city: string }
}
const nameLens = Lens().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'
`$3
`typescript
import { Prism } from '@fuiste/optics'type Person = {
name: string
address?: { street: string; city: string }
}
const addressPrism = Prism().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' } }
`$3
`typescript
import { Lens, Prism, Iso } from '@fuiste/optics'type Address = { street: string; city: string }
type Person = { name: string; address?: Address }
const addressPrism = Prism().of({
get: (p) => p.address,
set: (address) => (p) => ({ ...p, address }),
})
const cityLens = Lens
().prop('city')// Prism ∘ Lens => Prism
const cityPrism = Prism().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({ to: (n) =>
${n}, from: (s) => parseInt(s, 10) })
type Model = { count: number }
const countLens = Lens().prop('count')
const countAsString = Lens().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().of({
get: (m) => m.count,
set: (count) => (m) => ({ ...m, count }),
})
const countAsStringPrism = Prism().compose(countPrism, numberString)
countAsStringPrism.get({}) // undefined
countAsStringPrism.set('9')({}) // { count: 9 } // concrete values materialize
`$3
`typescript
type Company = { name: string; employees: Array<{ name: string; role: string }> }
const employeesLens = Lens().prop('employees')
const firstEmployeeLens = Lens().compose(
employeesLens,
Lens().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 immutably
`$3
`typescript
type Circle = { type: 'circle'; radius: number }
type Square = { type: 'square'; side: number }
type Shape = Circle | Squareconst circlePrism = Prism().of({
get: (s): Circle | undefined => (s.type === 'circle' ? s : undefined),
set: (circle) => (_) => circle,
})
const radiusLens = Lens().prop('radius')
const circleRadius = Prism().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 }
`$3
`typescript
type Configuration = {
search?: {
options?: { isPrefillEnabled?: boolean }
}
}const searchPrism = Prism().of({
get: (c) => c.search,
set: (search) => (c) => ({ ...c, search }),
})
const optionsPrism = Prism>().of({
get: (s) => s.options,
set: (options) => (s) => ({ ...s, options }),
})
const isPrefillEnabledPrism = Prism<
NonNullable['options']>
>().of({
get: (o) => o.isPrefillEnabled,
set: (isPrefillEnabled) => (o) => ({ ...o, isPrefillEnabled }),
})
const partialComposed = Prism().compose(searchPrism, optionsPrism)
const composed = Prism().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)({}) // unchanged
`---
Best 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 and Exclude to narrow optional shapes when building intermediate prisms---
API reference
$3
`typescript
// Lens factory for a source type S
Lens()
.prop(key: K): Lens
.compose(outer: Lens, inner: Lens | Prism | Iso): Lens | Prism// Prism factory for a source type S
Prism()
.of({ get: (s: S) => A | undefined; set: (a: A | ((a: A) => A)) => (s: T) => T }): Prism
.compose(outer: Prism, inner: Lens | Prism | Iso): Prism
// Iso constructor
Iso({ to: (s: S) => A, from: (a: A) => S }): Iso
`$3
`typescript
// A functional lens focusing a required value A inside source S
export type Lens = {
_tag: 'lens'
get: (s: S) => A
// Accepts either a value or an updater function
set: (a: A | ((a: A) => A)) => (s: T) => T
}// A functional prism focusing an optional/union value A inside source S
export type Prism = {
_tag: 'prism'
get: (s: S) => A | undefined
set: (a: A | ((a: A) => A)) => (s: T) => T
}
// A total, invertible mapping between S and A
export type Iso = {
_tag: 'iso'
to: (s: S) => A
from: (a: A) => S
}
`Notes:
-
Lens#set and Prism#set both accept a value or function and return a new object of the same structural type as the input. Unchanged branches are preserved
- Prism#get may return undefined. When using composed prisms, any missing outer branch results in undefined
- Prism#set on 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’s set. An exception is when composing with Iso: providing a concrete value will be materialized via the outer Prism#set, while providing a function remains a no-op if missing$3
`typescript
// Extract source/target types from optics
InferLensSource>
InferLensTarget>
InferPrismSource>
InferPrismTarget
>
InferIsoSource>
InferIsoTarget>
`Examples:
`typescript
const nameLens = Lens().prop('name')
type PersonFromLens = InferLensSource // Person
type Name = InferLensTarget // stringconst addressPrism = Prism().of({
get: (p) => p.address,
set: (a) => (p) => ({ ...p, address: a }),
})
type PersonFromPrism = InferPrismSource // Person
type Address = InferPrismTarget // { street: string; city: string }
`---
Examples from the test suite
$3
`typescript
type Address = { street: string; city: string }
type Person = { name: string; address: Address }const addressLens = Lens().prop('address')
const cityLens = Lens().prop('city')
const personCityLens = Lens().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 immutably
`$3
`typescript
type Address = { street: string; city: string }
type Person = { name: string; age: number; address?: Address }const addressPrism = Prism().of({
get: (p) => p.address,
set: (address) => (p) => ({ ...p, address }),
})
const cityLens = Lens().prop('city')
const composed = Prism().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'
`$3
`typescript
type Address = { street: string; city: string }
type Person = { name: string; age: number; address: Address }const addressLens = Lens().prop('address')
const cityPrism = Prism().of({
get: (a) => a.city,
set: (city) => (a) => ({ ...a, city }),
})
const composed = Lens().compose(addressLens, cityPrism)
composed.get({ name: 'John', age: 30, address: { street: '123', city: 'New York' } }) // 'New York'
`$3
`typescript
type Address = { street: string; city: string }
type Person = { name: string; age: number; address?: Address }const addressPrism = Prism().of({
get: (p) => p.address,
set: (a) => (p) => ({ ...p, address: a }),
})
const cityPrism = Prism().of({
get: (a) => a.city,
set: (city) => (a) => ({ ...a, city }),
})
const composed = Prism().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 }) // unchanged
`$3
`typescript
type Company = {
name: string
departments?: Array<{
name: string
manager?: { name: string; email: string }
}>
}const firstDepartmentPrism = Prism().of({
get: (c) => c.departments?.[0],
set: (dept) => (c) => ({
...c,
departments: c.departments ? [dept, ...c.departments.slice(1)] : [dept],
}),
})
const managerPrism = Prism[number]>().of({
get: (dept) => dept.manager,
set: (manager) => (dept) => ({ ...dept, manager }),
})
const composed = Prism().compose(firstDepartmentPrism, managerPrism)
composed.get({
name: 'Acme',
departments: [{ name: 'Eng', manager: { name: 'John', email: 'john@acme.com' } }],
})
// => { name: 'John', email: 'john@acme.com' }
`---
Tips and gotchas
- Composed prisms are safe-by-default: missing outer values mean
get returns undefined and set is a no-op
- If you want set to create missing structure, do it at the nearest prism with a set that materializes the branch
- Arrays are first-class: numeric prop keys are supported and type-checked
- Share interfaces across lenses: you can make a Lens