Lens
Lens Operations
Once you have a lens, you perform three fundamental operations: view (read), set (write), and over (transform).
Quick Reference
| Operation | Purpose | Signature |
|---|---|---|
view | Read value through lens | view(lens, source): A |
set | Update value | set(lens, value, source): S |
over | Transform value | over(lens, fn: (a: A) => A, source): S |
view() - Reading Values
Purpose: Extract the focused value from your data structure.
Signatures:
// Data-first
function view<S, A>(lens: Lens<S, A>, source: S): A
// Data-last (for pipes)
function view<S, A>(lens: Lens<S, A>): (source: S) => ABasic Usage
import { prop, path, view } from '@ontologyio/receta/lens'
import * as R from 'remeda'
interface User {
name: string
address: { city: string }
}
const user = { name: 'Alice', address: { city: 'Boston' } }
// Data-first
const name = view(prop<User, 'name'>('name'), user) // 'Alice'
const city = view(path<User, string>('address.city'), user) // 'Boston'
// Data-last (in pipe)
const result = R.pipe(
user,
view(prop<User, 'name'>('name'))
) // 'Alice'Real-World: API Response Parsing
interface GitHubPR {
data: {
repository: {
pullRequest: {
title: string
author: { login: string }
reviewDecision: string | null
}
}
}
}
const titleLens = path<GitHubPR, string>('data.repository.pullRequest.title')
const authorLens = path<GitHubPR, string>('data.repository.pullRequest.author.login')
const parsePR = (response: GitHubPR) => ({
title: view(titleLens, response),
author: view(authorLens, response),
})set() - Updating Values
Purpose: Update the focused value immutably.
Signatures:
// Data-first
function set<S, A>(lens: Lens<S, A>, value: A, source: S): S
// Data-last (for pipes)
function set<S, A>(lens: Lens<S, A>, value: A): (source: S) => SBasic Usage
const user = { name: 'Alice', age: 30 }
// Data-first
const updated = set(prop<User, 'name'>('name'), 'Bob', user)
// => { name: 'Bob', age: 30 }
// Original unchanged
console.log(user.name) // 'Alice'
// Data-last (in pipe)
const result = R.pipe(
user,
set(prop<User, 'name'>('name'), 'Charlie'),
set(prop<User, 'age'>('age'), 31)
)
// => { name: 'Charlie', age: 31 }Real-World: React State Update
interface AppState {
user: { name: string; email: string }
settings: { theme: 'light' | 'dark' }
}
const themeLens = path<AppState, string>('settings.theme')
const emailLens = path<AppState, string>('user.email')
// In React component
const updateTheme = () => {
setState((state) => set(themeLens, 'dark', state))
}
const updateEmail = (newEmail: string) => {
setState((state) => set(emailLens, newEmail, state))
}
// Multiple updates in sequence
const updateMultiple = () => {
setState((state) =>
R.pipe(
state,
set(themeLens, 'dark'),
set(emailLens, 'newemail@example.com')
)
)
}Real-World: Form Validation Result
interface Form {
fields: {
email: { value: string; error?: string }
password: { value: string; error?: string }
}
isValid: boolean
}
const emailErrorLens = path<Form, string | undefined>('fields.email.error')
const passwordErrorLens = path<Form, string | undefined>('fields.password.error')
const isValidLens = prop<Form, 'isValid'>('isValid')
const validateAndUpdate = (form: Form): Form => {
const emailError = validateEmail(form.fields.email.value)
const passwordError = validatePassword(form.fields.password.value)
return R.pipe(
form,
set(emailErrorLens, emailError),
set(passwordErrorLens, passwordError),
set(isValidLens, !emailError && !passwordError)
)
}over() - Transforming Values
Purpose: Read the current value, transform it, and set the result.
Signatures:
// Data-first
function over<S, A>(lens: Lens<S, A>, fn: (a: A) => A, source: S): S
// Data-last (for pipes)
function over<S, A>(lens: Lens<S, A>, fn: (a: A) => A): (source: S) => SBasic Usage
const user = { name: 'alice', age: 30 }
// Data-first
const older = over(prop<User, 'age'>('age'), (age) => age + 1, user)
// => { name: 'alice', age: 31 }
const caps = over(prop<User, 'name'>('name'), (name) => name.toUpperCase(), user)
// => { name: 'ALICE', age: 30 }
// Data-last (in pipe)
const result = R.pipe(
user,
over(prop<User, 'name'>('name'), (name) => name.toUpperCase()),
over(prop<User, 'age'>('age'), (age) => age + 1)
)
// => { name: 'ALICE', age: 31 }Real-World: Counter Increment
interface State {
counter: number
history: number[]
}
const counterLens = prop<State, 'counter'>('counter')
const historyLens = prop<State, 'history'>('history')
// Increment and record
const increment = (state: State): State =>
R.pipe(
state,
over(counterLens, (n) => n + 1),
over(historyLens, (history) => [...history, state.counter])
)Real-World: Shopping Cart Quantity
interface Cart {
items: Array<{ id: string; quantity: number; price: number }>
total: number
}
const itemsLens = prop<Cart, 'items'>('items')
const totalLens = prop<Cart, 'total'>('total')
// Update quantity for specific item
const updateQuantity = (cart: Cart, itemId: string, delta: number): Cart => {
return R.pipe(
cart,
over(itemsLens, (items) =>
items.map((item) =>
item.id === itemId
? { ...item, quantity: item.quantity + delta }
: item
)
),
// Recalculate total from the updated items
(updated) =>
set(
totalLens,
updated.items.reduce((sum, item) => sum + item.quantity * item.price, 0),
updated
)
)
}Real-World: Text Normalization
interface Article {
title: string
content: string
tags: string[]
}
const titleLens = prop<Article, 'title'>('title')
const contentLens = prop<Article, 'content'>('content')
const tagsLens = prop<Article, 'tags'>('tags')
// Normalize article text
const normalizeArticle = (article: Article): Article =>
R.pipe(
article,
over(titleLens, (title) => title.trim()),
over(contentLens, (content) => content.trim().replace(/\s+/g, ' ')),
over(tagsLens, (tags) => tags.map((tag) => tag.toLowerCase().trim()))
)Operation Patterns
Pattern 1: Conditional Updates
// Update only if condition is met
const updateIfPositive = (state: State, value: number): State => {
return value > 0 ? set(counterLens, value, state) : state
}
// Transform conditionally
const incrementIfEven = (state: State): State => {
return over(
counterLens,
(n) => (n % 2 === 0 ? n + 1 : n),
state
)
}Pattern 2: Chained Transformations
// Multiple transformations in sequence
const processUser = (user: User): User =>
R.pipe(
user,
over(prop<User, 'name'>('name'), (name) => name.trim()),
over(prop<User, 'name'>('name'), (name) => name.toLowerCase()),
over(prop<User, 'email'>('email'), (email) => email.toLowerCase()),
set(prop<User, 'updatedAt'>('updatedAt'), Date.now())
)Pattern 3: Accumulating Results
interface Stats {
count: number
sum: number
average: number
}
const countLens = prop<Stats, 'count'>('count')
const sumLens = prop<Stats, 'sum'>('sum')
const avgLens = prop<Stats, 'average'>('average')
const addValue = (stats: Stats, value: number): Stats =>
R.pipe(
stats,
over(countLens, (n) => n + 1),
over(sumLens, (sum) => sum + value),
over(avgLens, () => (stats.sum + value) / (stats.count + 1))
)Equivalence: over = view + transform + set
Understanding that over is just a convenience for view → transform → set:
// These are equivalent:
// Using over
const result1 = over(ageLens, (age) => age + 1, user)
// Using view + set
const currentAge = view(ageLens, user)
const newAge = currentAge + 1
const result2 = set(ageLens, newAge, user)
// result1 and result2 hold the same data, in two separate objectsWhen to use each:
- Use
overwhen you need the current value to compute the new value - Use
setwhen you already know the new value - Use
viewwhen you only need to read
Data-First vs Data-Last
All operations support both calling conventions:
// Data-first: All arguments at once
view(someLens, data)
set(someLens, value, data)
over(someLens, fn, data)
// Data-last: Partial application for pipes
view(someLens)(data)
set(someLens, value)(data)
over(someLens, fn)(data)
// In practice with pipes
R.pipe(
data,
view(someLens), // returns the focused value
// ... more transformations
)
R.pipe(
data,
set(someLens, value), // returns updated data
set(otherLens, otherValue), // chain updates
)
R.pipe(
data,
over(someLens, fn), // returns transformed data
over(otherLens, otherFn), // chain transformations
)Common Mistakes
Mistake 1: Forgetting Immutability
// ❌ Wrong - mutating directly
const wrong = (user: User) => {
user.name = 'Bob' // MUTATION!
return user
}
// ✅ Correct - using lens
const correct = (user: User) => set(nameLens, 'Bob', user)Mistake 2: Using set Instead of over
// ❌ Wordy - reading, then setting
const increment = (state: State) => {
const current = view(counterLens, state)
return set(counterLens, current + 1, state)
}
// ✅ Correct - using over
const increment = (state: State) => over(counterLens, (n) => n + 1, state)Mistake 3: Wrong Pipe Order
// ❌ Wrong - view returns the value, not the source
R.pipe(
user,
view(nameLens), // returns string
set(ageLens, 31) // ERROR: expects User, got string
)
// ✅ Correct - separate pipes or use only set/over
R.pipe(
user,
set(nameLens, 'Bob'),
set(ageLens, 31)
)
// Or extract value separately
const name = view(nameLens, user)
const updated = set(ageLens, 31, user)Performance Considerations
Lens Creation is Cheap
// ✅ Fine to create inline
setState((state) => set(prop<State, 'counter'>('counter'), 10, state))
// ✅ Also fine to cache
const counterLens = prop<State, 'counter'>('counter')
setState((state) => set(counterLens, 10, state))Multiple Updates
// ✅ Reads top to bottom
R.pipe(
state,
set(lens1, value1),
set(lens2, value2),
set(lens3, value3)
)
// ⚠️ The same work, and the same copies, with more noise
let result = state
result = set(lens1, value1, result)
result = set(lens2, value2, result)
result = set(lens3, value3, result)What's Next?
- Composition → - Combining lenses for deep access
- Patterns - Real-world recipes (React, Redux, Forms)
- API Reference - Complete function signatures
Quick links: