Lens Constructors
Lens constructors create the "focusing glasses" that let you zoom into specific parts of your data. This guide covers all five ways to create lenses.
Quick Reference
| Constructor | Use For | Example |
|---|---|---|
prop | Single object property | prop<User, 'name'>('name') |
path | Nested properties (dot notation) | path<User, string>('address.city') |
index | Array element at specific position | index<Todo>(0) |
lens | Custom logic (advanced) | lens(getter, setter) |
optional | Nullable/optional fields with Option | optional(prop<User, 'email'>('email')) |
prop() - Property Lens
Purpose: Focus on a single property of an object.
Signature:
function prop<S, K extends keyof S>(key: K): Lens<S, S[K]>When to use: You need to access/update a direct property of an object. Pass both type arguments, the object and the key: TypeScript can't infer one and take the other.
Basic Usage
import { prop, view, set, over } from '@ontologyio/receta/lens'
interface User {
name: string
email: string
age: number
}
const user: User = {
name: 'Alice',
email: 'alice@example.com',
age: 30,
}
// Create lenses for each property
const nameLens = prop<User, 'name'>('name')
const emailLens = prop<User, 'email'>('email')
const ageLens = prop<User, 'age'>('age')
// Read
view(nameLens, user) // 'Alice'
// Write
set(nameLens, 'Bob', user)
// => { name: 'Bob', email: 'alice@example.com', age: 30 }
// Transform
over(ageLens, (age) => age + 1, user)
// => { name: 'Alice', email: 'alice@example.com', age: 31 }Real-World: React Form Field
import { useState } from 'react'
interface FormState {
username: string
password: string
rememberMe: boolean
}
const usernameLens = prop<FormState, 'username'>('username')
const passwordLens = prop<FormState, 'password'>('password')
const rememberMeLens = prop<FormState, 'rememberMe'>('rememberMe')
// In React component
const LoginForm = () => {
const [form, setForm] = useState<FormState>(initialForm)
const updateField = <A,>(lens: Lens<FormState, A>, value: A) => {
setForm((state) => set(lens, value, state))
}
return (
<>
<input
value={form.username}
onChange={(e) => updateField(usernameLens, e.target.value)}
/>
<input
type="password"
value={form.password}
onChange={(e) => updateField(passwordLens, e.target.value)}
/>
<input
type="checkbox"
checked={form.rememberMe}
onChange={(e) => updateField(rememberMeLens, e.target.checked)}
/>
</>
)
}Real-World: Redux Reducer
interface AppState {
counter: number
theme: 'light' | 'dark'
isLoading: boolean
}
const counterLens = prop<AppState, 'counter'>('counter')
const themeLens = prop<AppState, 'theme'>('theme')
const loadingLens = prop<AppState, 'isLoading'>('isLoading')
// Reducer with lenses - no nested spreads!
const reducer = (state: AppState, action: Action): AppState => {
switch (action.type) {
case 'INCREMENT':
return over(counterLens, (n) => n + 1, state)
case 'TOGGLE_THEME':
return over(
themeLens,
(theme) => (theme === 'light' ? 'dark' : 'light'),
state
)
case 'SET_LOADING':
return set(loadingLens, action.payload, state)
default:
return state
}
}path() - Nested Path Lens
Purpose: Focus on deeply nested properties using dot notation.
Signature:
function path<S, A = unknown>(pathStr: string): Lens<S, A>When to use: You need to access/update properties multiple levels deep. Paths step through plain objects and arrays, so items.0.name reaches into the first item. The path string isn't checked against the type, and the value type you pass is taken on trust.
Basic Usage
interface User {
name: string
address: {
street: string
city: string
zip: string
country: {
name: string
code: string
}
}
}
const user: User = {
name: 'Alice',
address: {
street: '123 Main St',
city: 'Boston',
zip: '02101',
country: {
name: 'USA',
code: 'US',
},
},
}
// Create path lenses
const cityLens = path<User, string>('address.city')
const countryCodeLens = path<User, string>('address.country.code')
// Read nested values
view(cityLens, user) // 'Boston'
view(countryCodeLens, user) // 'US'
// Update nested values - no spread hell!
set(cityLens, 'NYC', user)
// => { name: 'Alice', address: { ..., city: 'NYC', ... } }
set(countryCodeLens, 'CA', user)
// => { name: 'Alice', address: { ..., country: { name: 'USA', code: 'CA' } } }Real-World: Stripe Settings
interface StripeSettings {
account: {
business: {
name: string
taxId: string
address: {
line1: string
city: string
postalCode: string
country: string
}
}
branding: {
logo: string
primaryColor: string
icon: string
}
}
paymentMethods: {
card: { enabled: boolean }
bankTransfer: { enabled: boolean }
}
}
// Define lenses for deep paths
const businessNameLens = path<StripeSettings, string>(
'account.business.name'
)
const businessCityLens = path<StripeSettings, string>(
'account.business.address.city'
)
const logoLens = path<StripeSettings, string>('account.branding.logo')
const cardEnabledLens = path<StripeSettings, boolean>(
'paymentMethods.card.enabled'
)
// Update multiple deep fields
const updatedSettings = R.pipe(
settings,
set(businessNameLens, 'Acme Corp'),
set(businessCityLens, 'San Francisco'),
set(logoLens, 'https://cdn.example.com/logo.png'),
set(cardEnabledLens, true)
)Real-World: GitHub PR State
interface PRState {
repository: {
owner: string
name: string
}
pullRequest: {
number: number
title: string
author: {
username: string
avatarUrl: string
}
checks: {
ci: { status: 'pending' | 'success' | 'failure' }
lint: { status: 'pending' | 'success' | 'failure' }
}
}
}
// Path lenses for deep state
const prTitleLens = path<PRState, string>('pullRequest.title')
const ciStatusLens = path<PRState, string>('pullRequest.checks.ci.status')
const lintStatusLens = path<PRState, string>(
'pullRequest.checks.lint.status'
)
// Update checks when webhook arrives
const handleWebhook = (state: PRState, checkName: string, status: string) => {
const lens =
checkName === 'ci'
? ciStatusLens
: lintStatusLens
return set(lens, status, state)
}index() - Array Element Lens
Purpose: Focus on a specific element in an array by index.
Signature:
function index<A>(idx: number): Lens<readonly A[], A | undefined>When to use: You need to update a specific array element immutably.
The focus is A | undefined, because the index may be out of bounds. There, view returns undefined and set changes nothing. Setting undefined also changes nothing, so index can't remove an element. The lens works on readonly A[], so declare an array field as readonly to compose index with prop.
Basic Usage
import { index, view, set, over } from '@ontologyio/receta/lens'
const numbers = [10, 20, 30, 40, 50]
// Create index lenses
const firstLens = index<number>(0)
const thirdLens = index<number>(2)
// Read element
view(firstLens, numbers) // 10
view(thirdLens, numbers) // 30
// Update element
set(firstLens, 100, numbers)
// => [100, 20, 30, 40, 50]
set(thirdLens, 300, numbers)
// => [10, 20, 300, 40, 50]
// Transform element (n is undefined when the index is out of bounds)
over(firstLens, (n) => (n ?? 0) * 2, numbers)
// => [20, 20, 30, 40, 50]Real-World: Todo List
interface Todo {
id: string
text: string
done: boolean
priority: 'low' | 'medium' | 'high'
}
interface TodoListState {
todos: readonly Todo[]
}
const todosLens = prop<TodoListState, 'todos'>('todos')
const firstTodoLens = index<Todo>(0)
const secondTodoLens = index<Todo>(1)
// Mark first todo as done
const markFirstDone = (state: TodoListState) => {
// Compose todosLens with firstTodoLens
const firstTodo = compose(todosLens, firstTodoLens)
return over(
firstTodo,
(todo) => (todo ? { ...todo, done: true } : todo),
state
)
}
// Update priority of second todo
const updateSecondPriority = (state: TodoListState, priority: Todo['priority']) => {
const secondTodo = compose(todosLens, secondTodoLens)
return over(
secondTodo,
(todo) => (todo ? { ...todo, priority } : todo),
state
)
}Real-World: Shopping Cart
interface CartItem {
productId: string
name: string
quantity: number
price: number
}
interface Cart {
items: readonly CartItem[]
total: number
}
const itemsLens = prop<Cart, 'items'>('items')
// Update quantity of specific cart item
const updateItemQuantity = (
cart: Cart,
itemIndex: number,
newQuantity: number
): Cart => {
const itemLens = index<CartItem>(itemIndex)
const itemsWithIndex = compose(itemsLens, itemLens)
return over(
itemsWithIndex,
(item) => (item ? { ...item, quantity: newQuantity } : item),
cart
)
}
// Remove item (setting undefined changes nothing, so filter instead)
const removeItem = (cart: Cart, itemIndex: number): Cart => {
return over(
itemsLens,
(items) => items.filter((_, idx) => idx !== itemIndex),
cart
)
}lens() - Custom Lens
Purpose: Create a lens with custom get/set logic for complex scenarios.
Signature:
function lens<S, A>(
get: (source: S) => A,
set: (value: A) => (source: S) => S
): Lens<S, A>When to use: Built-in constructors don't fit your needs (rare).
Basic Usage
import { lens, view, set, over } from '@ontologyio/receta/lens'
interface User {
firstName: string
lastName: string
}
// Custom lens for full name
const fullNameLens = lens<User, string>(
// Getter: combine first + last
(user) => `${user.firstName} ${user.lastName}`,
// Setter: split and update both fields
(fullName) => (user) => {
const [firstName, ...rest] = fullName.split(' ')
const lastName = rest.join(' ')
return { ...user, firstName, lastName: lastName || user.lastName }
}
)
const user = { firstName: 'Alice', lastName: 'Smith' }
view(fullNameLens, user) // 'Alice Smith'
set(fullNameLens, 'Bob Johnson', user)
// => { firstName: 'Bob', lastName: 'Johnson' }Real-World: Temperature Conversion
interface WeatherData {
temperatureCelsius: number
humidity: number
pressure: number
}
// Lens that reads/writes in Fahrenheit, stores in Celsius
const temperatureFahrenheitLens = lens<WeatherData, number>(
// Get: C to F
(data) => (data.temperatureCelsius * 9) / 5 + 32,
// Set: F to C
(fahrenheit) => (data) => ({
...data,
temperatureCelsius: ((fahrenheit - 32) * 5) / 9,
})
)
const weather = { temperatureCelsius: 20, humidity: 65, pressure: 1013 }
view(temperatureFahrenheitLens, weather) // 68
set(temperatureFahrenheitLens, 77, weather)
// => { temperatureCelsius: 25, humidity: 65, pressure: 1013 }Real-World: Computed Total
interface Invoice {
subtotal: number
taxRate: number
total: number // computed from subtotal + tax
}
// Lens for subtotal that auto-updates total
const subtotalLens = lens<Invoice, number>(
(invoice) => invoice.subtotal,
(newSubtotal) => (invoice) => ({
...invoice,
subtotal: newSubtotal,
total: newSubtotal * (1 + invoice.taxRate),
})
)
const invoice = { subtotal: 100, taxRate: 0.1, total: 110 }
set(subtotalLens, 200, invoice)
// => { subtotal: 200, taxRate: 0.1, total: 220 }optional() - Optional Value Lens
Purpose: Handle nullable/optional fields safely with Option type.
Signature:
function optional<S, A>(baseLens: Lens<S, A | undefined>): Lens<S, Option<A>>When to use: Working with optional/nullable fields using the Option pattern.
Basic Usage
import { prop, optional, view, set, over } from '@ontologyio/receta/lens'
import { some, none, isSome, map as mapOption } from '@ontologyio/receta/option'
interface User {
name: string
email?: string
phone?: string
}
const emailLens = prop<User, 'email'>('email')
const optionalEmailLens = optional(emailLens)
const user1 = { name: 'Alice', email: 'alice@example.com' }
const user2 = { name: 'Bob' }
// View returns Option
view(optionalEmailLens, user1) // Some('alice@example.com')
view(optionalEmailLens, user2) // None
// Set with Some/None
set(optionalEmailLens, some('newemail@example.com'), user2)
// => { name: 'Bob', email: 'newemail@example.com' }
set(optionalEmailLens, none(), user1)
// => { name: 'Alice', email: undefined }
// Transform with Option operations
over(
optionalEmailLens,
(opt) => mapOption(opt, (email) => email.toLowerCase()),
user1
)
// => { name: 'Alice', email: 'alice@example.com' }Real-World: Profile Bio
interface Profile {
username: string
bio?: string
website?: string
location?: string
}
const bioLens = prop<Profile, 'bio'>('bio')
const optionalBioLens = optional(bioLens)
// Provide default bio if missing
const ensureBio = (profile: Profile): Profile => {
return over(
optionalBioLens,
(opt) => (isSome(opt) ? opt : some('No bio provided')),
profile
)
}
// Truncate long bios
const truncateBio = (profile: Profile, maxLength: number): Profile => {
return over(
optionalBioLens,
(opt) =>
mapOption(opt, (bio) =>
bio.length > maxLength ? bio.slice(0, maxLength) + '...' : bio
),
profile
)
}Choosing the Right Constructor
Decision Tree
Do you need custom logic (e.g., computed values)?
├─ Yes → use lens()
└─ No
├─ Single property?
│ ├─ Required → use prop()
│ └─ Optional → use prop() + optional()
│
├─ Nested property?
│ └─ use path()
│
└─ Array element?
└─ use index()Examples
// ✅ prop - single required field
const nameLens = prop<User, 'name'>('name')
// ✅ path - nested field
const cityLens = path<User, string>('address.city')
// ✅ index - array element
const firstLens = index<Todo>(0)
// ✅ prop + optional - nullable field
const emailLens = optional(prop<User, 'email'>('email'))
// ✅ lens - custom logic needed
const fullNameLens = lens<User, string>(
(user) => `${user.firstName} ${user.lastName}`,
(name) => (user) => ({ ...user, ...splitName(name) })
)Comparison Table
| Constructor | Type Safety | Nested Support | Custom Logic | Nullable Support |
|---|---|---|---|---|
prop | ✅ Full | ❌ | ❌ | ⚠️ Manual |
path | ⚠️ Runtime | ✅ | ❌ | ⚠️ Manual |
index | ✅ Full | ❌ | ❌ | ✅ Returns A? |
lens | ✅ Full | ✅ | ✅ | ✅ Custom |
optional | ✅ Full | ❌ | ❌ | ✅ Option |
What's Next?
Now that you know how to create lenses, learn how to use them:
- Operations → - view, set, over
- Composition - Combining lenses for deep access
- Patterns - Real-world recipes
Quick links:
Overview
Lenses provide a composable, type-safe way to read and update deeply nested data structures without mutation. Think "dot notation that works for updates" with full immutability guarantees.
Lens Operations
Once you have a lens, you perform three fundamental operations: view (read), set (write), and over (transform).