Tuesday, December 30, 2025

Functional programming in JavaScript (6)

Please take a look at other posts about functional programming in JavaScript:

  1. Part 1 - what functional programming is about
  2. Part 2 - functional pipelines
  3. Part 3 - the Y combinator
  4. Part 4 - monads
  5. Part 5 - the Trampoline
  6. Part 6 - Lenses

State management is one of fundamental topics. Consider a state:

let state = { 
  user: { 
    age: 35,
    address: { 
      city: 'Warsaw' } 
    } 
  };

When state changes, an usual way of modifying it would be to just overwrite a part of it:

state.user.address.city = 'Prague';

In the functional world, updating an object is considered a bad practice. Instead, in a functional approach the state would be immutable which means that instead of a local modification, a new state is created. There are good reasons for that:

  • a complete new state means that there is no chance of race condition issues in a concurrent environment
  • a state change history can be tracked which allows so called time travel debugging

Because of this, it is common to encourage immutable states and if you ever studied/used React, you certainly saw something like:

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age':
      return { ...state, user: { ...state.user, age: state.user.age + 1 } };
    case 'changed_city':
      return { ...state, user: { ...state.user, address: { city: action.new_name } } };
    default:
      throw Error('Unknown action: ' + action.type);
  }
}

console.log( JSON.stringify( state ) );
state = reducer(state, { type: 'changed_city', new_name: 'Prague' } );
console.log( JSON.stringify( state ) );

Note how inconvenient is to create a new state when a deeply nested property is modified. The JavaScript's spread operator (...) helps a bit but still, imagine having dozens of such statemens in your code where modified props are deep down in the state. People often call this the Spread Operator Hell.

A functional approach to this problem, where a new state is to be created from existing state in an atomic way, are Lenses. A Lens lets us have a functional getter and setter (for a property, for an index of an array) and focuses on just a tiny portion of a possibly huge data. A Lens has two operations:

  • view - gets the value the lens focuses on
  • over - applies a given function to the value the lens focuses on
const lens = (getter, setter) => ({
  view: (obj) => getter(obj),
  over: (fn, obj) => setter(fn(getter(obj)), obj)
});

Simple? To view a value, you use the getter. To modify, you use the gettter to get the value, apply a function over it (that's why it's called over) and use setter to set the value back.

We can define our first lens, the Prop lens or Prop-based lens as it takes a property name of an object:

const prop = (key) => lens(
  (obj) => obj[key],                      // The Getter
  (val, obj) => ({ ...obj, [key]: val })  // The Setter (immutable!)
);

Still simple? Should be, JavaScript helps us here as both getting and setting a property of an object, given the property's name, is supported by the language.

And, that's it. Let's see the example. It composes three property lenses into a new lens that focuses on a property deep down in the state:

const userL    = prop('user');
const addressL = prop('address');
const cityL    = prop('city');

// compose them to focus deep into the state
const userCityLens = {
  view: (obj)     => cityL.view(addressL.view(userL.view(obj))),
  over: (fn, obj) => userL.over(u => addressL.over(a => cityL.over(fn, a), u), obj)
};

const currentCity = userCityLens.view(state); 
console.log( currentCity );
state = userCityLens.over(z => 'Prague', state);
console.log( JSON.stringify( state ) );

Take a close look of how the composition is done and make sure you are comfortable with the order of view/over application in the composed lens.

An interesting feature of the lens is that the function can actually operate on the original value (just like an ordinary property setter; lens just does it using a function!) e.g.:

state = userCityLens.over(z => z.toUpperCase(), state);

Kind of disappointing? Let's see what can be improved here - it's the manual composition of lenses! Creating a new lens from existing lenses should be yet another operation!

Well, here it is:

const compose2Lenses = (l1, l2) => ({
  // To view: go through l1, then l2
  view: (obj) => l2.view(l1.view(obj)),  
  // To update: l1.over wraps the result of l2.over
  over: (fn, obj) => l1.over(target => l2.over(fn, target), obj)
});

// A variadic version to compose any number of lenses
const composeLenses = (...lenses) => lenses.reduce(compose2Lenses);

Note how we start by combining just two lenses and then we make a variadic version that just combines an arbitrary number of lenses.

The composed lens can be now defined as:

const userCityLens = composeLenses(userL, addressL, cityL);

Nice! Ready for another step forward? How about replacing the Prop-based lens that takes a property name with another lens, let's call it the Path-based lens (or Selector-based lens) that just takes an arrow function that points to the exact spot in the state object we want to focus on? So we could have:

const userCityLens = selectorLens( s => s.user.address.city );

Compare the two, which one looks better and feels more understandable? I'd prefer the latter. However, to have it, we'd have to somehow parse the given arrow function definition so that the lens is able to learn that it has to follow this path: user -> address -> city. Sounds difficult?

Well, it is. There are two approaches. One would be to stringify the function and parse it to retrieve the path. Another one, preferable, would be to use the JavaScript's Proxy to record the path by just running the function over an object! I really like the latter approach:

const tracePath = (selector) => {
  const path = [];
  const proxy = new Proxy({}, {
    get(_, prop) {
      path.push(prop);
      return proxy; 
    }
  });
  selector(proxy);
  return path;
};

const setterFromPath = (path, fn, obj) => {
  if (path.length === 0) return fn(obj);
  const [head, ...tail] = path;
  return {
    ...obj,
    [head]: setterFromPath(tail, fn, obj[head])
  };
};

const selectorLens = (selector) => {
  const path = tracePath(selector);
  return {
    view: (obj) => selector(obj),
    over: (fn, obj) => setterFromPath(path, fn, obj)
  };
};

A bit of explanation.

First, the tracePath is supposed to get a function and execute it over an empty object. Assuming the function is an arrow function like s => s.user.address.city, the getter will be called 3 times and the path ['user', 'address', 'city'] would be recorded.

Second, the setterFromPath is just a recursive way of applying a function over a path to a given object.

And last, the lens just uses the two auxiliary functions to define view and over.

And how it's used? Take a look:

const userCityLens = selectorLens( s => s.user.address.city );

const currentCity = userCityLens.view(state);
console.log( currentCity );
state = userCityLens.over(z => "Prague", state);
console.log( JSON.stringify( state ) );

And yes, it works! That ends the example.

There are other interesting lenses, like the Prism which handles the case of possible non-existence of the data, in a way similar to the Maybe monad. It's similar to the conditional getter built into the language:

const city = state?.user?.address?.city;

but the interesting thing about the Prism is that it handles both the getter and the setter, while the built-in ?. operator cannot be used at left side of the assignment.

No comments: