/lenses

experiment with `over<S, T, A, B>(source: S, update: (a: A): B): Over<S, T, A, B>`

Primary LanguageTypeScriptMIT LicenseMIT

Lenses

@atomic-object/lenses is a small functional lens library for TypeScript with the goal of being small, with zero dependencies, and strong, precise types. It is inspired by Aether, for F#.

Lenses are getter/setter pairs that let you represent a location within some data structure for both reading and updating that location. "Update" in this case is in the functional sense – not by mutation, but by creating a new data structure with a new value in the location of interest. A little like a pointer offset in C, but memory-, type-, and mutation-safe.

Basic Usage

The simplest use is to represent a property of an object. Consider this type

type Something = { foo: number; bar: string };

We could define a helper module with lenses for interacting with this type:

export namespace Something = {
  export const foo = Lens.from<Something>().prop("foo");
  export const bar = Lens.from<Something>().prop("bar");
}

In this example, Something.foo has type Lens<Something, number>, meaning that it can read numbers from and write numbers to a Something. bar is inferred to have type Lens<Something, string> – it only accepts string values.

Given a value of type Something:

let o: Something = { foo: 1, bar: "hello" };

we can get values

// Get the foo of a Something
expect(Something.foo.get(o)).toBe(1);
// Or just treat the lens as a function to do the same thing:
expect(Something.foo(o)).toBe(1);
expect(Something.bar(o)).toEqual("hello")

And we can create updated by setting the lens:

let o2 = Something.foo.set(o, 10);
expect(o2).toEqual({ foo: 10, bar: "hello" });
expect(o).toEqual({ foo: 1, bar: "hello" });

Or update-ing the value by running it through a function:

let o3 = Something.foo.update(o, i => i+1);
expect(o3).toEqual({ foo: 2, bar: 'hello'})

Lens Composition

Lenses can also be composed. This is a powerful technique for building abstractions. While immutability helper and spreads require deep knowledge about the shape of a data structure, violating the Law of Demeter, lenses represent concepts, and programming to them decouples you from the underlying data structure.

For example, consider a container type which contains a Something and a value of that type:

type ContainsSomething = {
  something: Something;
};
const container: ContainsSomething = {
  something: { foo: 19, bar: "hola" }
};

We can create a lens for the something property, and compose it with our other lenses:

let innerFoo = Lens.from<ContainsSomething>()
  .prop("something")
  .comp(Something.foo);
expect(innerFoo(container)).toEqual(19);

Users of our innerFoo lens don't need to couple themselves to either the location of Something within ContainsSomething, nor the location of the logical value of foo within it. We're completely free to reorganize our data structure, provided all users of it are programmed to lenses.

Currying

Both set and update are curried – you can provide just a target value or an update function to get back "updater" functions (e.g. Something => Something) that can be composed together to make multiple updates at once.

For example,

import { flow } from "lodash";
let o5 = flow(
  Something.foo.update(i => 10 * i),
  Something.bar.set("world")
)(o);

Custom Lenses

Lenses need not point simply to properties of an object, but can be used for anything that could be logically get/set. The underlying representation need not matter.

For example, we could create a lens that presents the low bit of an integer as a boolean:

const lowBitLens = Lens.of<number, boolean>({
  get: n => (n & 1 ? true : false),
  set: (n, b) => (b ? n | 1 : n & ~1)
});

Given this definition, we're free to read/write booleans into numbers as follows:

expect(lowBitLens(10)).toBe(false);
expect(lowBitLens(11)).toBe(true);
expect(lowBitLens.set(10, true)).toBe(11);
expect(lowBitLens.set(11, false)).toBe(10);
expect(lowBitLens.update(9, b => !b)).toBe(8);

Isomorphisms

In addition to creating lenses with Lens.of that operate on arbitrary substructure – or even equivalent substructure, such as the low-bit lens example – you can also map from one lens type to another.

For example, let's say you have a menu component that takes a MenuProps. You have ApplicationState in your redux store that you want to control your menu, but that state may be in charge of other things as well that should all be consistent. If you can provide a bi-directional mapping between your application state and a MenuProps, you could always convert your app state into menu inputs, and changes to the menu props back into equivalent changes in your application state. Your menu, therefore, can think it is operating on a MenuProps when instead it's updating ApplicationState.

For a simpler example, let's look at an isomorphism that converts numbers to strings, and use it to create a lens that operates on strings, but stores the value as a number.

let n2s: Isomorphism<number, string> = {
  to: n => n.toString(),
  from: s => parseInt(s, 10)
};
const sFoo = Lens.map(Something.foo, n2s);

let o: Something = { foo: 1, bar: "hello" };
expect(sFoo(o)).toEqual("1");
const o6 = sFoo.set(o, "1234");
expect(o6).toEqual({ foo: 1234, bar: "hello" });

Prisms

We also provide a type for Prisms, which are lenses for which get may fail, returning undefined. See the code/tests for more examples.

Functional array helpers

@atomicobject/lenses/lib/arrays provides functional versions of splice, pop, push, unshift, and shift, as well as an index function which returns a Prism for read/writing an arbitrary index in an array.