Composable Data Access with Lenses | Claudiu Ivan<br>Skip to main content
Jun 4, 2026
Composable Data Access with Lenses
Updating a field several levels into a nested immutable object means copying the path above the target and rebuilding the enclosing object by hand. If an intermediate copy is missing, the returned object may still share a nested reference with the original. A later mutation through that shared reference can affect both copies. In TypeScript, this kind of accidental reference sharing usually passes the compiler, and code review catches it inconsistently.
In financial systems this shows up constantly. Interest rate products and valuation contexts are often nested several levels deep. The same problem appears in other domains where business rules produce nested object shapes.
Immutability helps because it makes state changes predictable, but it has a practical cost: updating nested data can become noisy enough that the intent disappears.
Path duplication is a second cost. When a field path is used outside the module that owns the domain model, it stops being an implementation detail and becomes part of a broader contract. Repeating that contract as raw property access makes schema changes harder to review and harder to test.
A lens is a functional programming abstraction for reading and immutably updating one focused part of a larger structure. In TypeScript, lenses give shared paths a name and move fragile object-copying code behind a testable interface. A previous article on type-safe error handling argued that absence and failure deserve representation in the type system. Lenses extend the same idea to data access paths.
Try it in StackBlitz ->. The companion code pairs the lens implementations with property tests and a small token-consuming example. You can step through the modules, run the tests, and modify the examples to see how the type system and runtime checks respond.
The Problem
Consider a simplified interest rate swap:
interface IRS {<br>readonly id: string;<br>readonly notionalAmount: number;<br>readonly fixedLeg: Leg;<br>readonly floatingLeg: Leg;
interface Leg {<br>readonly paymentFrequency: string;<br>readonly dayCountConvention: string;<br>readonly rate: Rate;
type Rate = FixedRate | FloatingRate;
interface FixedRate {<br>readonly type: "Fixed";<br>readonly value: number;
interface FloatingRate {<br>readonly type: "Floating";<br>readonly index: string;<br>readonly spread: number;<br>If we want to update the spread on the floating leg without mutating the original object, the direct implementation is:
function updateSpreadManually(irs: IRS, newSpread: number): IRS {<br>if (irs.floatingLeg.rate.type !== "Floating") {<br>return irs;
return {<br>...irs,<br>floatingLeg: {<br>...irs.floatingLeg,<br>rate: {<br>...irs.floatingLeg.rate,<br>spread: newSpread,<br>},<br>},<br>};<br>For one update, this is probably fine. The problem starts when this pattern appears fifty times, each copy slightly different, each one depending on the developer remembering the exact shape of the object.
The failures tend to be mundane: a copied top level with a mutated nested object, a missing spread, or a schema change that misses one hand-written update path. Discriminated unions add another source of drift because each update has to remember which branch it is handling.
A codebase should not rely on “be careful” as its enforcement mechanism. If a path through a domain object matters, it deserves a name.
The Lens Type
A lens is a pair of functions that knows how to look at part of a structure and how to replace that part immutably.
type ResultT, E> =<br>| { readonly _tag: "Ok"; readonly value: T }<br>| { readonly _tag: "Err"; readonly error: E };
type ViewResultA> = ResultA, string>;<br>type SetResultS> = ResultS, string>;
interface LensS, A> {<br>readonly view: (source: S) => ViewResultA>;<br>readonly set: (source: S, newValue: A) => SetResultS>;<br>S is the source, the larger structure. A is the part being focused. Returning Result from every operation adds some ceremony for typed property lenses. For a well-typed path, the Err branch is effectively dead, but the caller still has to unwrap it. That cost matters less once paths move into runtime configuration, where lookup can genuinely fail. Keeping one lens type lets typed and configured paths compose through the same API.
A lens from IRS to Leg can focus on the floating leg. A lens from Leg to Rate can focus on the rate. Composing them gives a lens from IRS directly to the floating leg’s rate.
For the floating-rate example, each segment of the path gets its own lensProp:
const floatingLegLens = lensPropIRS, "floatingLeg">("floatingLeg");<br>const rateLens = lensPropLeg, "rate">("rate");<br>composeLens turns those segments into a direct focus on the nested rate:
const floatingRateLens = composeLens(floatingLegLens, rateLens);<br>The composed set first reads the intermediate value. It delegates the update to the inner lens, then writes the updated intermediate value back through the outer lens. If either step fails, the...