Type-Safe, Immutable, Readable - Pick any two?

Published: 2020-07-31 by Lars  codethoughts

For quite some time, I have been looking for a better way to do immutable updates of deep object structures in TypeScript, to avoid convoluted code like this:

const newState = {
  ...state,
  chat: {
    ...state.chat,
    contact: {
      ...state.chat.contact,
      [key]: {
        ...state.chat.contact[key],
        name: 'Laura'
      }
    }
  }
};

Let's say we have some data:

const state = {
  chat: {
    contact: {
      '1': { id: '1', name: 'Lars' },
      '2': { id: '2', name: 'Kristian' }
    }
  }
};

const key = '1';

If I want to change my name to Laura, I can mutate the data like this:

state.chat.contact[key].name = 'Laura';

However, when we use this kind of data in a React + Redux application, we need to make an immutable update, which will return a copy of the data but sharing as much as possible with the source data. To do this in vanilla JavaScript is quite unreadable:

const newState = {
  ...state,
  chat: {
    ...state.chat,
    contact: {
      ...state.chat.contact,
      [key]: {
        ...state.chat.contact[key],
        name: 'Laura'
      }
    }
  }
};

However, there are plenty of libraries that can help with this, and I have been happy with Ramda, where I can write the immutable update like this:

const newState = R.assocPath(
  ['chat', 'contact', key, 'name'],
  'Laura',
  state
);

However, this is not type-safe. With TypeScript, we would have a type declaration for our State, like this:

type State = {
  chat: {
    contact: {
      [key: string]: { id: string; name: string };
    };
  };
};

When using TypeScript, we want the type-system to catch type-errors at compile-time, but this faulty update compiles in Typescript all fine and only fails at run-time:

const newState: State = R.assocPath(
  ['chat', 'wrong', key, 'name'],
  'Laura',
  state
);

Going back to vanilla TypeScript makes the code type-safe, so this code fails to type-check:

const newState: State = {
  ...state,
  chat: {
    ...state.chat,
    wrong: {
      ...state.chat.contact,
      [key]: {
        ...state.chat.contact[key],
        name: 'Laura'
      }
    }
  }
};

with this error message: Object literal may only specify known properties, and 'wrong' does not exist in type '{ contact: { [key: string]: { id: string; name: string; }; }; }'.

The problem with the vanilla JavaScript and TypeScript code is that there is a lot of duplication. The closer to the root of the data, the more times a property name is repeated, e.g. the chat property name is repeated 4 times. This is cumbersome to write, and also not very easy to read.

Is there really no way to get all 3?

Functional programming has a concept called "Lenses" to handle immutable updates, and there are libraries, like monocle-ts and Shades.js that allow us to write type-safe, immutable and mostly readable code like this:

mod('chat', 'contact', key, 'name')
  (newName)
  (state);

This is not bad, but having to write the property names as strings means we will miss out on suggestions and refactoring support from our IDE.

While researching this issue, I came upon the engineforce/ImmutableAssign/ library which uses Proxy in a clever way to create a somewhat better solution with type-safe, immutable and mostly readable code like this:

const newState = iassign(
  state,
  s => s.chat.contact[key],
  c => ({ ...c, name: 'Laura' })
);

Then I came upon this PR to F# proposing a nice syntax: F# RFC FS-1049 - Nested Record Field Copy and Update Expression which would allow me to write F#-code like this:

let newState = { state with chat.contact[key].name = 'Laura' }

It turns out that we can approximate this syntax using the Proxy implementation, so we can write type-safe, immutable, readable and writable code like this:

const newState: State = update(state)
  .set(state => state.chat.contact[key])
  .to(c => ({ ...c, name: 'Laura' }));

Now the IDE can provide helpful type error messages:

and suggestions:

This syntax can be implemented using a couple of helper classes to facilitate the fluent syntax, building on top of a core assoc function. First the fluent API:

class Assoc<S> {
  private s: S;
  constructor(s: S) {
    this.s = s;
  }
  set<P>(selector: (s: S) => P): AssocTo<S, P> {
    return new AssocTo(this.s, selector);
  }
}

class AssocTo<S, P> {
  private s: S;
  private selector: (s: S) => P;
  constructor(s: S, selector: (s: S) => P) {
    this.s = s;
    this.selector = selector;
  }
  to(creator: (p: Readonly<P>) => P): S {
    return assoc(this.selector, creator, this.s);
  }
}

export const update: <S>(s: S) => Assoc<S> = s => {
  return new Assoc(s);
};

The assoc function is implemented by instrumenting the property getter function to record all the property accesses into a path variable:

export const assoc: <S, P>(
  get: (s: S) => P,
  set: (p: Readonly<P>) => P,
  s: S
) => S = (get, set, s) => {
  const path = [] as string[];
  const sInstrumented = instrument(s, path, 0);
  get(sInstrumented); // Note: compute "path", ignore return value, as it is the "wrong" object
  const originalValue = get(s);
  const newValue = set(originalValue);
  return R.assocPath(path, newValue, s);
};

const instrument: (obj: any, path: string[], level: number) => any = (
  obj,
  path,
  level
) => {
  const handlers = {
    get: (_: any, key: string) => {
      const value = obj[key];
      if (level === path.length) {
        path.push(key);
        if (typeof value === 'object' && value != null) {
          return instrument(value, path, level + 1);
        }
      }
      return value;
    }
  };
  return new Proxy(shallowCopy(obj), handlers);
};

const shallowCopy = (value: any) => {
  if (value != undefined && !(value instanceof Date)) {
    if (value instanceof Array) {
      return value.slice();
    } else if (typeof value === 'object') {
      return Object.assign({}, value);
    }
  }
  return value;
};

Using that path variable, we simply use Ramda's assocPath to actually carry out the immutable update. We can safely use assocPath as an implementation detail here, without worrying about type-safety, since the fluent API already does all the type-checking that we need.

Should we worry about the performance of using Proxy? We probably shouldn't, as data updates are usually several orders of magnitudes less frequent than data reads in most React applications. Nonetheless, a bit of performance testing shows that while this implementation is slower by a factor of 2 compared to using the raw untyped assocPath function from Ramda, it does clock in at 200k update operations per second on my laptop.

So, with this implementation we can now have type-safe immutable, readable AND writable updates like this:

const newState = update(state)
  .set(state => state.chat.contact[key])
  .to(c => ({ ...c, name: 'Laura' }));

Sample repo with code for this blog post can be found at github.com/larsthorup/assoc-demo.

Discuss on Twitter