npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

@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/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: '[email protected]' } }],
})
// => { name: 'John', email: '[email protected]' }

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