Navigation Menu

Skip to content

Instantly share code, notes, and snippets.

@glebec
Last active January 25, 2019 23:06
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save glebec/ec79b56a9bd4fe75d35fb057953dbd82 to your computer and use it in GitHub Desktop.
Save glebec/ec79b56a9bd4fe75d35fb057953dbd82 to your computer and use it in GitHub Desktop.
Lenses in JS
/**
* Van Laarhoven Lenses in JavaScript
* by Gabriel Lebec (https://github.com/glebec)
*
* Based on https://www.codewars.com/kata/lensmaker
* See also https://github.com/ekmett/lens/wiki/History-of-Lenses
*/
/**
* Composition and Combinators
*/
// the K combinator, creates a function "fixated" on a value
const konst = a => _ => a
// the C combinator, flips a binary function's inputs
const flip = f => a => b => f(b)(a)
// the B combinator, composes two functions (right-to-left)
const compose = f => g => a => f(g(a))
// left-to-right function composition
const pipe = flip(compose)
// abusing function prototype to have infix function composition
Function.prototype.c = function(g) {
return compose(this)(g)
}
Function.prototype.p = function(g) {
return pipe(this)(g)
}
/**
* Two Key Functors
*/
// The Identity functor just maps its internal value. It's fairly trivial.
class Identity {
constructor(a) {
this.val = a
}
static of(...args) {
return new Identity(...args)
}
map(f) {
return Identity.of(f(this.val))
}
}
// The Const functor ignores mapping. It's useful for hanging onto a result.
class Const {
constructor(a) {
this.val = a
}
static of(...args) {
return new Const(...args)
}
map(_) {
return this
}
}
/**
* Lens Functions
*
* Lens s a :: Functor f => (a -> f a) -> (s -> f s)
*
* A lens is a single function which acts as both a getter and a setter
* (depending on how it is used). The lens itself is not used directly on
* the datatype of interest, but rather specialized with the use of helper
* functions like `view`, `over`, and `set` which act on lenses to produce
* getters & setters.
*
* `Lens s a` can be read as "a lens between container `s` and focus `a`".
*/
// `view` specializes the lens to be a getter.
// view :: Lens s a -> s -> a
// view :: Functor f => ((a -> f a) -> (s -> f s)) -> s -> a
const view = lens => s => lens(Const.of)(s).val
// `over` specializes the lens to be an immutable transformer.
// over :: Lens s a -> (a -> a) -> s -> s
// over :: Functor f => ((a -> f a) -> (s -> f s)) -> (a -> a) -> s -> s
const over = lens => a2a => s => lens(a2a.p(Identity.of))(s).val
// `set` specializes the lens to be an immutable setter.
// set :: Lens s a -> a -> s -> s
// set :: Functor f => ((a -> f a) -> (s -> f s)) -> a -> s -> s
const set = lens => a => over(lens)(konst(a))
/**
* Example Lenses and Demonstrations
*/
// pair :: [a, b]
// _1 :: Lens [a, b] a // a lens from pairs to the first element
// _1 :: Functor f => (a -> f a) -> [a, b] -> f [a, b]
const _1 = a2fa => ([a, b]) => a2fa(a).map(a2 => [a2, b])
// _2 :: Lens [a, b] b // a lens from pairs to the second element
// _2 :: Functor f => (b -> f b) -> [a, b] -> f [a, b]
const _2 = b2fb => ([a, b]) => b2fb(b).map(b2 => [a, b2])
// Notice that we are taking a pair, [a, b], placing one element into some
// functor `f`, and calling `map` to build a new pair with one element changed.
// Note, if the functor used is `Const`, the call to `map` is effectively
// ignored and instead we keep the extracted value as-is (wrapped inside the
// `Const` functor). The function `view` uses `Const` and returns the value
// wrapped inside that functor.
console.log(view(_1)([true, false])) // true
console.log(view(_2)([true, false])) // false
// However, if the functor used is `Identity`, this map succeeds and we have
// built an immutably-updated new pair. The function `set` uses `Identity` and
// returns the (new) value from inside that functor.
console.log(set(_1)('hi')([true, false])) // ['hi', false]
console.log(set(_2)('yo')([true, false])) // [true, 'yo']
/**
* Composing Lenses
*
* Because lenses are just functions, they compose directly.
*
* Lens s a :: Functor f => (a -> f a) -> (s -> f s)
* Lens a x :: Functor f => (x -> f x) -> (a -> f a)
* Lens s a . Lens a x = Lens s x
*
* This creates a composite lens from `s` focused on a deeply-nested `x`.
*/
const _1_1_1 = _1.c(_1).c(_1)
const _2_2_2 = _2.c(_2).c(_2)
console.log(view(_1_1_1)([[[true]]]))
console.log(view(_2_2_2)([false, [false, [false, true]]]))
console.log(view(_1.c(_2).c(_1))([[false, [true]]]))
// Of course, merely accessing nested data in JS is easy. This is comically
// overwrought for that use case. But what about immutable update? Still works!
console.log(set(_1_1_1)(5)([[[true, false], false], false])) // [[[5, false], false], false]
/**
* Idiomatic JS Use Case: Objects
*/
// `prop` is a lens factory – a function for creating lenses.
// prop :: String -> Lens Object a
// prop :: Functor f => String -> (a -> f a) -> (Object -> f Object)
const prop = key => a2fa => obj =>
a2fa(obj[key]).map(a => ({ ...obj, [key]: a }))
// Some example lenses
const friend = prop('friend')
const name = prop('name')
const pet = prop('pet')
// Sample data
const person = {
name: 'Old MacGregor',
friend: {
name: 'Old MacDonald',
pet: {
name: 'Miss Piggy',
age: 'nunya business',
},
},
}
const friendPetName = friend.c(pet).c(name)
// viewing deeply nested data works fine…
console.log(view(friendPetName)(person))
// but so does updating!
console.log(set(friendPetName)('Kermit')(person))
// and mapping.
console.log(over(friendPetName)(s => s + '!!!')(person))
@rockymadden
Copy link

rockymadden commented Jan 3, 2019

Fantastic, thank you! Have some complementary lens operations in a local lib leveraging Ramda and Sanctuary-Def. I was attempting to break down and enforce the signature of the lens itself and just so happened to come across your profile and this gist via https://gist.github.com/Avaq/1f0636ec5c8d6aed2e45#gistcomment-2776771.

@glebec
Copy link
Author

glebec commented Jan 25, 2019

@rockymadden glad to be of help. I should add that in a typed functional setting, lens signatures are usually formulated as Lens s t a b (two extra type parameters), which can be read as "a lens from container s to focus a, which can be transformed into a container t with focus b". This allows lenses to be used in an even more general way. In JS we can change the types of things with zero ceremony, so I didn't use the full type signature; an Object whose .age is a String is considered the "same type" as an Object whose .age is a Number.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment