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:
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) // trueWhen 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?
- Patterns → - Real-world recipes (React, Redux, Forms)
- API Reference - Complete function reference