Skip to content

Fuiste/optics

Repository files navigation

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/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 => Iso

Core 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

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 materialize

Arrays

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 immutably

Union 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)({}) // 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<T> and Exclude<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#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

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 immutably

Prism ∘ 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 }) // unchanged

Complex 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: '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<Interface>() and use it safely wherever the interface applies

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published