Skip to content
Jared Schraub
Go back

Stop Building Spread Pyramids: Composable Focus in TypeScript

Edit page

A stack of precisely aligned glass lens elements focusing a single luminous beam down through many layers onto one small glowing point, with the surrounding layers left dark and untouched

At the end of the state piece I shipped a reducer with a case I openly admitted I didn’t like:

case "note_edited":
  return {
    ...state,
    drafts: {
      ...state.drafts,
      [event.id]: { ...state.drafts[event.id], note: event.note },
    },
  };

Three spreads to change one string. It is correct. It is also the only case in that reducer where I have to count braces before I believe it. I said then that there was a cleaner way to reach a value buried that deep, and that it would get a piece of its own. This is the piece.

That version is still defensible. Nobody ships three spreads and calls it a crisis, which is exactly why it survives. It gets worse on its own, six months later, when the orders view grows a settings panel, the way every internal tool eventually does:

type Digest = "daily" | "instant";

type ViewSettings = {
  notifications: {
    channels: {
      email: { enabled: boolean; address: string; digest: Digest };
      slack: { enabled: boolean; webhook: string };
    };
    quietHours: { start: number; end: number };
  };
  table: { density: "compact" | "comfortable" };
};

Now a user flips the email digest from daily to instant. That one value sits five levels below the root of your state. Here is the immutable update, written the way you have written it a hundred times:

const setDigest = (state: ViewState, digest: Digest): ViewState => ({
  ...state,
  settings: {
    ...state.settings,
    notifications: {
      ...state.settings.notifications,
      channels: {
        ...state.settings.notifications.channels,
        email: { ...state.settings.notifications.channels.email, digest },
      },
    },
  },
});

Count what is actually in there. One assignment that means something, and five spreads whose only job is to copy the objects on the way down. Then read it again and look for the bug you would never catch in review: nothing stops you writing ...state.settings.notifications where the channels copy belongs. Those two objects are similar enough that TypeScript waves some of these transpositions through, and the failure mode is a settings panel that quietly forgets your Slack webhook.

You are hand-writing the rebuild

The pyramid is not a style problem. It is how immutable updating actually works, showing through.

An immutable update cannot write into the object holding your value, so it builds a new one. But whatever used to point at that object is now pointing at a stale copy, so it has to be rebuilt too. And its parent. All the way to the root. Changing digest means constructing five new objects: a new email, a new channels, a new notifications, a new settings, and a new state. Everything hanging off to the side is untouched and shared by reference rather than copied, which is why slack, quietHours, table and drafts cost you nothing.

That walk down and back up is one operation with a definite shape, and your spread pyramid is that operation transcribed by hand, one brace at a time.

An immutable update walks down the path and rebuilds on the way back upA vertical chain of five nodes runs from state down through settings, notifications, channels, to email, ending at the digest leaf. A blue arrow descends the left side labelled get, walking down to the value. A green arrow ascends the right side labelled set, rebuilding each node on the path, with every node on the chain marked new. Branches to the right of each node, drafts, table, quiet hours and slack, are drawn with dashed connectors labelled shared, not copied.statesettingsnotificationschannelsemaildigestdraftstablequietHoursslackshared,not copiedget: walk down to the valueset: rebuild every node on the way backchanging one value costs five new objects · the spread pyramid is you writing them out by hand

The bug is never in the value you meant to change. It is in the transcription. So stop transcribing.

A focus you can hold

Here is the whole idea, and it is smaller than the problem it solves. A path into a structure is two functions that agree with each other: one that reads the value out, one that puts a new value back.

type Lens<S, A> = {
  get: (s: S) => A;
  set: (a: A, s: S) => S;
};

const lens = <S, A>(
  get: (s: S) => A,
  set: (a: A, s: S) => S
): Lens<S, A> => ({ get, set });

S is the structure, A is the thing you are pointing at. That pair is called a lens, and the name is the good kind of literal: it focuses on one part of a bigger thing without losing the rest.

Almost every lens you will ever write points at one property, so write that one once:

const prop = <S, K extends keyof S>(key: K): Lens<S, S[K]> =>
  lens(
    s => s[key],
    (a, s) => ({ ...s, [key]: a })
  );

You write that spread once. Every level of every path below reuses prop rather than restating it, so all the copying you were doing by hand now happens in one line you can test.

In practice you rarely read or write state outright. You change it, based on what is already there, and that takes one more function:

const modify =
  <S, A>(l: Lens<S, A>, f: (a: A) => A) =>
  (s: S): S =>
    l.set(f(l.get(s)), s);

Read the body against the U-turn diagram above. l.get(s) is the walk down. f is the one thing you meant to do. l.set is the rebuild back up. The whole operation, which the pyramid smeared across thirteen lines, is now a single function that works for any path.

Snap two focuses together

One property deep is not the problem. Five is. This is where a lens stops being a tidier setter and starts earning its keep, because a lens is a value, and two of them join into a third.

const compose = <S, A, B>(
  outer: Lens<S, A>,
  inner: Lens<A, B>
): Lens<S, B> =>
  lens(
    s => inner.get(outer.get(s)),
    (b, s) => outer.set(inner.set(b, outer.get(s)), s)
  );

The getter reads left to right. The setter is the U-turn again in miniature: set the inner value, then set that result back into the outer. Each level you used to write out by hand is one more application of this function.

Composing lenses end to endFour lens segments sit in a row, labelled settings, notifications, channels and email. Type labels at each joint read ViewState, ViewSettings, Notifications, Channels and Email, so the output type of each segment is the input type of the next. Below them a single wide bar spans the whole row, labelled emailLens, running from ViewState on the left to Email on the right.settingsnotificationschannelsemailViewStateViewSettingsNotificationsChannelsEmailemailLensViewStateEmailthe types line up at every joint, so four focuses are one focus

So build the path once, name it, and keep it:

const emailLens = compose(
  compose(
    compose(
      prop<ViewState, "settings">("settings"),
      prop<ViewSettings, "notifications">("notifications")
    ),
    prop<ViewSettings["notifications"], "channels">("channels")
  ),
  prop<ViewSettings["notifications"]["channels"], "email">("email")
);

And the thirteen-line pyramid becomes the line it always wanted to be:

const setDigest = (state: ViewState, digest: Digest) =>
  modify(emailLens, e => ({ ...e, digest }))(state);

emailLens is not a helper for this one update. It is the location itself, promoted to a value. Any code that needs the email settings takes that same path, whether it is reading them, toggling enabled, or correcting the address. None of it can spell the path differently, because there is only one spelling left. The old helpers each knew a route. This one is the route.

The draft that might not be there

Now back to the case that started this. state.drafts is a Record<string, Draft>, and the draft for a given order may not exist yet. A lens promises the value is there. A lens pointed into drafts cannot make that promise.

So the focus needs an honest type, and it already exists: Option from the errors piece, the box for a value that is simply absent, with no story to tell about why.

type Optional<S, A> = {
  getOption: (s: S) => Option<A>;
  set: (a: A, s: S) => S;
};

const at = <A>(key: string): Optional<Record<string, A>, A> => ({
  getOption: r =>
    key in r ? { some: true, value: r[key] } : { some: false },
  set: (a, r) => ({ ...r, [key]: a }),
});

And a modify that does nothing when there is nothing to modify:

const modifyOptional =
  <S, A>(o: Optional<S, A>, f: (a: A) => A) =>
  (s: S): S => {
    const focus = o.getOption(s);
    return focus.some ? o.set(f(focus.value), s) : s;
  };

Compose a lens with an optional and you get an optional, because one maybe-missing step anywhere along a path makes the whole path maybe-missing. It is the same U-turn as before, carrying an Option on the way down:

const composeOptional = <S, A, B>(
  outer: Lens<S, A>,
  inner: Optional<A, B>
): Optional<S, B> => ({
  getOption: s => inner.getOption(outer.get(s)),
  set: (b, s) => outer.set(inner.set(b, outer.get(s)), s),
});

Which finally settles the case that opened the piece:

const draftOf = (id: string) =>
  composeOptional(prop<ViewState, "drafts">("drafts"), at<Draft>(id));

case "note_edited":
  return modifyOptional(
    draftOf(event.id),
    d => ({ ...d, note: event.note })
  )(state);

One expression, and I no longer count braces. But notice what the optional just forced into the open. The original wrote ...state.drafts[event.id] on a key that might not exist. Spreading undefined is legal and contributes nothing, so the object gets built from scratch and the draft is quietly created. Was that deliberate, or an accident nobody noticed? The pyramid never made you answer. The optional does, because “edit it if it is there” and “create it if it is not” are now two different pieces of code. If you want the version that creates, you want a lens with a default rather than an optional. Both are fine. Choosing on purpose is the point.

Every draft at once

One more shape comes up constantly, and it is the one you would normally reach for a loop to solve. The user hits “discard all notes”, and you need to touch every draft rather than one.

type Traversal<S, A> = {
  modifyAll: (f: (a: A) => A, s: S) => S;
};

const eachValue = <A>(): Traversal<Record<string, A>, A> => ({
  modifyAll: (f, r) =>
    Object.fromEntries(Object.entries(r).map(([k, v]) => [k, f(v)])),
});

const composeTraversal = <S, A, B>(
  outer: Lens<S, A>,
  inner: Traversal<A, B>
): Traversal<S, B> => ({
  modifyAll: (f, s) => outer.set(inner.modifyAll(f, outer.get(s)), s),
});

There is no get here, because there is no single value to get. Otherwise it is the same U-turn once more: walk down, transform whatever you find, rebuild on the way back. Which makes “discard all notes” one expression:

const everyDraft = composeTraversal(
  prop<ViewState, "drafts">("drafts"),
  eachValue<Draft>()
);

const clearNotes = (state: ViewState) =>
  everyDraft.modifyAll(d => ({ ...d, note: undefined }), state);

A lens focuses one thing that is definitely there. An optional focuses one thing that might be. A traversal focuses however many are there, including none. All three do the same two jobs, reading and rebuilding. The only thing that changes is how many targets they hit.

Immer already writes the pyramid for you

Here is the objection I would raise if I were reading this, and it is a good one.

React developers have a tool for exactly this pain, and it is not lenses. It is Immer, and it deletes the pyramid without any of the above:

const setDigest = (state: ViewState, digest: Digest) =>
  produce(state, draft => {
    draft.settings.notifications.channels.email.digest = digest;
  });

You write a mutation, Immer hands you a proxy, and you get a correctly structure-shared immutable copy out the other side. If your problem is the thirteen lines, that is the answer, and most React teams should reach for it. I am not going to pretend otherwise to protect my thesis. Redux Toolkit ships it by default for good reason.

What Immer gives you is a better way to perform the update. What it does not give you is the path as a thing. draft.settings.notifications.channels.email is a sequence of keystrokes inside one callback. You cannot name it, hand it to a function, or pass it to a generic form component and let that component read and write through it. It only writes, too. Reading the same path is a separate expression you type out again. So every place that needs that location spells it out for itself, and spelling it out is what put the transposition bug in the pyramid in the first place.

Immer fixes the ceremony. A lens fixes the duplication. Reach for Immer when the update is the whole problem. Reach for a lens when the same location shows up in six files.

Where the tax is real

Two honest limits, and the second is worse than most optics enthusiasts will tell you.

Shallow updates do not need any of this. { ...state, statusFilter: status } is one line, it is obvious, and wrapping it in a lens makes it longer and less clear. A focus earns its keep at depth, at repetition, or when it needs to be passed around. One level, one caller, no lens.

The bigger tax is inference. Read that emailLens again and notice every prop call carries explicit type arguments. That is not me being pedantic. Drop them and this is what you get:

const notifications = compose(prop("settings"), prop("notifications"));
//                                   ~~~~~~~~~~
// error TS2345: Argument of type '"settings"' is not assignable
// to parameter of type 'never'.

TypeScript cannot infer S for the first prop from the composition it is about to take part in, so keyof S collapses to never and the key you passed is rejected. The fix is to annotate, and you annotate at every level, which means the deeper the path the more type noise you write to describe a path you already spelled out. This is the same wall the hand-rolled pipe hit back at the start of this series, for the same reason, and it is why the real optics libraries exist. optics-ts and monocle-ts solve it with heavy type machinery and a builder syntax, and they pay for it in compile time and in error messages you will need a quiet afternoon to read. Try them on one deep path before you convert a codebase.

You have been using optics

Every spread pyramid you have ever written was one of these, inlined and thrown away at the point of use. The three things above are not three tricks. They are one family, and it has a name: optics. A lens, an optional, and a traversal are its most useful members, and the only thing separating them is how many targets they point at.

And they are held to laws, in the sense the opening piece meant when it said the language wants equations rather than recipes. Two of them, for a lens:

l.set(l.get(s), s) === s     // put back what you took, nothing changed
l.get(l.set(a, s)) === a     // take what you just put, get it back

Read them as what they are. A lens is not a convention about how to write setters. It is a claim about behaviour that either holds or does not, and if it holds for two lenses, it holds for their composition. That is the whole reason compose is safe to stack as deep as the path goes. The equations compose, so the code composes.

The laws are testable, so test them

Look at those two lines once more, because they are not documentation. They are executable claims, quantified over every s and every a you could ever pass, which means the three examples you would write by hand cover a rounding error’s worth of the cases they assert.

That goes well beyond lenses. The reducer from the state piece has laws too. So does the parser from the boundary piece. So does every pure function this series has built, and purity is precisely the property that makes those claims checkable by a machine instead of by you, at a scale you would never reach with hand-picked inputs. You state the law. Something else goes looking for the input that breaks it, and hands you the smallest version of the story.

That is the next piece: Break Your Own Code First.

Until then, the next time you find yourself three spreads deep, stop and ask what you are actually writing. Not an update. A route, transcribed from memory, for the eleventh time. Write the route down once and give it a name. The pyramid was never the cost of immutability. It was the cost of not naming where you were going.


Edit page
Share this post on:

Previous Post
Break Your Own Code First: Property-Based Testing in TypeScript
Next Post
It Doesn't Forget, and That's the Problem: Why Your Chat History Isn't a Handoff

⌘K to toggleEsc to close