receta
Lens

Lens Composition

Lenses compose naturally, allowing you to build complex deep-access patterns from simple building blocks.

The compose() Function

Purpose: Combine two lenses to create a lens that focuses deeper.

Signature:

Signature
function compose<S, A, B>(outer: Lens<S, A>, inner: Lens<A, B>): Lens<S, B>

Mental Model: compose(outer, inner) means "first apply outer, then inner"

Basic Composition

import { prop, compose, view, set } from '@ontologyio/receta/lens'

interface User {
  name: string
  address: Address
}

interface Address {
  street: string
  city: string
}

const addressLens = prop<User, 'address'>('address')
const cityLens = prop<Address, 'city'>('city')

// Compose to access user's city
const userCityLens = compose(addressLens, cityLens)

const user = {
  name: 'Alice',
  address: { street: '123 Main', city: 'Boston' },
}

view(userCityLens, user) // 'Boston'
set(userCityLens, 'NYC', user)
// => { name: 'Alice', address: { street: '123 Main', city: 'NYC' } }

Composition vs Path

// These are equivalent:

// Using composition
const cityLens1 = compose(
  prop<User, 'address'>('address'),
  prop<Address, 'city'>('city')
)

// Using path
const cityLens2 = path<User, string>('address.city')

// Both work the same way
view(cityLens1, user) === view(cityLens2, user) // true

When to use each:

  • Use path() for simple dot-notation access (more concise)
  • Use compose() when you need to build up lenses dynamically or reuse lens pieces

Multi-Level Composition

interface State {
  user: {
    profile: {
      settings: {
        theme: string
      }
    }
  }
}

// Build up step by step
const userLens = prop<State, 'user'>('user')
const profileLens = prop<State['user'], 'profile'>('profile')
const settingsLens = prop<State['user']['profile'], 'settings'>('settings')
const themeLens = prop<State['user']['profile']['settings'], 'theme'>('theme')

// Compose multiple levels
const themeFromState = compose(
  compose(compose(userLens, profileLens), settingsLens),
  themeLens
)

// Or use path for the same effect
const themeFromState2 = path<State, string>('user.profile.settings.theme')

Real-World: Shopping Cart Items

interface CartItem {
  id: string
  quantity: number
}

interface CartState {
  cart: {
    items: readonly CartItem[]
  }
}

const cartLens = prop<CartState, 'cart'>('cart')
const itemsLens = prop<CartState['cart'], 'items'>('items')

// Access the first item
const firstItemLens = compose(
  compose(cartLens, itemsLens),
  index<CartItem>(0)
)

// Update first item quantity
const state = { cart: { items: [{ id: '1', quantity: 2 }] } }
over(
  firstItemLens,
  (item) => item && { ...item, quantity: item.quantity + 1 },
  state
)
// => { cart: { items: [{ id: '1', quantity: 3 }] } }

index may find nothing, so its focus is CartItem | undefined. A prop<CartItem, 'quantity'> lens can't compose onto that, so the update handles the missing item itself.

Real-World: Form Field Errors

interface FormState {
  fields: {
    email: { value: string; error?: string }
    password: { value: string; error?: string }
  }
}

const fieldsLens = prop<FormState, 'fields'>('fields')
const emailFieldLens = prop<FormState['fields'], 'email'>('email')
const emailErrorLens = prop<FormState['fields']['email'], 'error'>('error')

// Compose to access email error
const emailErrorFromForm = compose(
  compose(fieldsLens, emailFieldLens),
  emailErrorLens
)

// Or simpler with path
const emailErrorPath = path<FormState, string | undefined>(
  'fields.email.error'
)

Composition Laws

Lens composition is associative:

// These are equivalent
const lens1 = compose(compose(a, b), c)
const lens2 = compose(a, compose(b, c))

// (a . b) . c = a . (b . c)
view(lens1, data) === view(lens2, data)

Dynamic Composition

// Build lenses dynamically
const buildFieldLens = <K extends keyof FormFields>(fieldName: K) => {
  const fieldsLens = prop<Form, 'fields'>('fields')
  const fieldLens = prop<FormFields, K>(fieldName)
  return compose(fieldsLens, fieldLens)
}

const emailLens = buildFieldLens('email')
const passwordLens = buildFieldLens('password')

What's Next?

← Back to Operations

On this page