Skip to main content
Composable Data Access with Lenses
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 any intermediate spread is missing, the returned object shares a reference with the original at that level, and a later update will mutate both copies. The compiler does not detect this, and code review finds 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.

Lenses are a functional programming idea for focusing on part of a larger structure. In TypeScript, they 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 {
  readonly id: string;
  readonly notionalAmount: number;
  readonly fixedLeg: Leg;
  readonly floatingLeg: Leg;
}

interface Leg {
  readonly paymentFrequency: string;
  readonly dayCountConvention: string;
  readonly rate: Rate;
}

type Rate = FixedRate | FloatingRate;

interface FixedRate {
  readonly type: "Fixed";
  readonly value: number;
}

interface FloatingRate {
  readonly type: "Floating";
  readonly index: string;
  readonly spread: number;
}

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 {
  if (irs.floatingLeg.rate.type !== "Floating") {
    return irs;
  }

  return {
    ...irs,
    floatingLeg: {
      ...irs.floatingLeg,
      rate: {
        ...irs.floatingLeg.rate,
        spread: newSpread,
      },
    },
  };
}

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 Result<T, E> =
  | { readonly _tag: "Ok"; readonly value: T }
  | { readonly _tag: "Err"; readonly error: E };

type ViewResult<A> = Result<A, string>;
type SetResult<S> = Result<S, string>;

interface Lens<S, A> {
  readonly view: (source: S) => ViewResult<A>;
  readonly set: (source: S, newValue: A) => SetResult<S>;
}

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 = lensProp<IRS, "floatingLeg">("floatingLeg");
const rateLens = lensProp<Leg, "rate">("rate");

composeLens turns those segments into a direct focus on the nested rate:

const floatingRateLens = composeLens(floatingLegLens, rateLens);

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 failure is propagated as Err instead of being thrown. The caller does not need to remember how many levels of object spread are required.

const updatedIrs = floatingRateLens.set(irs, {
  type: "Floating",
  index: "SOFR",
  spread: 0.004,
});
if (isErr(updatedIrs)) {
  // handle the failure
}

Naming the path changes the maintenance model. A named path can be tested in code review without re-reading object spread syntax. That trust is only earned if the lens behaves predictably under composition and repeated updates, which is what the lens laws describe.

Lens Laws

Lenses come with three laws. They are short enough to memorize and useful as tests.

The view-set law: if viewing succeeds, setting the same value back should leave the source unchanged.

const viewed = lens.view(source);
if (isOk(viewed)) {
  deepEqual(lens.set(source, viewed.value), ok(source));
}

The set-view law: if setting succeeds, immediately viewing the updated source should return the value you set.

const updated = lens.set(source, value);
if (isOk(updated)) {
  deepEqual(lens.view(updated.value), ok(value));
}

The set-set law: setting a value and then setting another value is equivalent to setting the second value directly.

const first = lens.set(source, a);
if (isOk(first)) {
  deepEqual(lens.set(first.value, b), lens.set(source, b));
}

The laws make good tests because they are easy to state and easy to violate. A broken lens is worse than no abstraction because it gives a bad update path a trustworthy name. The laws are expressed over all valid inputs rather than one hand-picked example, which is what property-based testing is for. The companion code exercises each law with fast-check.

Typed property lenses satisfy the laws for well-typed objects by construction. Generated lenses are the more important case to test. If set creates intermediate objects, widens arrays, or skips a runtime type check, viewing after setting may not return the value that was set. The same laws should run against representative fixtures and production-like configuration before deployment.

So far every focused path is chosen at compile time. Real systems often need paths that come from configuration, and that boundary changes what the type system can prove.

Runtime-Configured Lenses

Runtime configuration is useful when a path has to cross a system boundary. Formula engines are the clearest example: formulas can use stable tokens such as IRS.Notional while configuration maps each token to a concrete path. A lens walks that path.

A minimal configuration needs source and target types plus a path:

interface LensConfig {
  readonly sourceType: string;
  readonly targetType: string;
  readonly getterPath: ReadonlyArray<string | number>;
}

const optionStrikeConfig: LensConfig = {
  sourceType: "EuropeanCallOption",
  targetType: "number",
  getterPath: ["strike"],
};

createLensFromConfig walks that path at runtime. The factory returns Err when the configuration is invalid, and the generated lens returns Err instead of throwing when traversal cannot continue.

function createLensFromConfig<S extends object, A>(
  config: LensConfig
): Result<Lens<S, A>, string>;

The same Result channel that is mostly ceremony for typed paths becomes necessary once paths come from configuration. At that boundary, lookup can fail even when the TypeScript types compile.

Generated lenses lose some of the guarantees that made the hand-written version attractive. A string path is not type-safe. TypeScript cannot prove that ["floatingLeg", "rate", "spread"] is a valid path for IRS, that the value at the end is a number, or that the rate is in the Floating variant by the time it is reached. The moment you move access paths into runtime configuration, you move some failures from compile time to runtime.

Configuration-driven access can still be the right design, but the inputs have to be treated as untrusted.

A production version needs clear behavior for missing paths, runtime type mismatches, and discriminated unions reached through configuration. It also needs to decide whether set can create missing structure or only update paths that already exist. Its errors should include enough context to identify which mapping failed and where traversal stopped.

Stable Names for Moving Paths

PresentValue = DiscountFactor * IRS.Notional

A formula referencing IRS.Notional does not need to change when the underlying path moves. Only the configuration changes:

const notionalConfig: LensConfig = {
  sourceType: "IRS",
  targetType: "number",
  getterPath: ["notionalAmount"],
};

After a schema migration that moves notional under trade.economics.notional.amount, only the mapping changes:

const notionalConfigV2: LensConfig = {
  sourceType: "IRS",
  targetType: "number",
  getterPath: ["trade", "economics", "notional", "amount"],
};

A configured lens is the mechanism that resolves the token against a runtime object. It can fail because the token has no configuration, the configuration is malformed, the configured path does not exist on the current object, or the resolved value has the wrong runtime type.

Configurable systems treat these as expected failure modes. Return Err and log the token and path. Validate the mapping against fixtures before deployment.

The companion formula example is intentionally small. Formulas ask for tokens. Lenses resolve those tokens, and callers receive typed success or failure.

Token Mappings as a Contract

Typed lenses live inside a module. Runtime-configured lenses cross between systems.

Token mappings need a clear owner because they are part of the public contract between the domain model and the systems that consume it. A change to the underlying object shape can break consumers without changing a single formula. At that point, lens configuration belongs to a specific team and needs validation before deployment.

During a schema migration, IRS.Notional might move from notionalAmount to trade.economics.notional.amount. Formulas keep referencing IRS.Notional; only the mapping changes. A validation job loads representative v2 fixtures and resolves the token. It fails the deployment if the old path is still configured or the result is not a number. Without that check, the abstraction has only moved a property access bug into configuration.

A production setup makes ownership and compatibility explicit. Each token namespace needs an owner. Each mapping targets a known schema version. Representative fixtures prove that the configured path still resolves. The team also has to decide whether failures block deployment or surface at request time.

The failure has to point back to the mapping. Could not resolve IRS.Notional is better than Cannot read property amount of undefined, but the useful version identifies the failed mapping, including its configured path and expected type.

Lenses create a place to attach ownership and compatibility checks to paths that already mattered but were previously scattered through the codebase.

Other Optics

Classical lenses assume the path always exists and points to a single value. This article’s Result-returning lenses make failure explicit, but optional fields and discriminated unions still need more precise optics. Collections need their own treatment too.

A discriminated union like Rate needs conditional access. The spread exists only when the rate is floating. Prisms focus on one possible variant of a larger type.

Arrays and collections need a different abstraction. Updating every matching item is what traversals are for.

The boundary is about the shape of the access. Lenses fit a single path to a single value. Prisms fit conditional access to one variant. Traversals fit updates over many possible targets.

Where This Leaves the Pattern

Lenses are useful when path knowledge is already duplicated across a codebase. They give repeated nested access a name, make immutable updates easier to review, and create a place to test the behavior.

Runtime configuration is a narrower case. It is useful when paths cross a boundary, such as formula definitions or client mappings, but it weakens type guarantees and needs validation against representative fixtures.

The value is in making an existing dependency explicit. When several parts of a system already rely on the same nested path, a lens gives that path a name and a test surface.

References