-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Showing
1 changed file
with
280 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,280 @@ | ||
# microlens | ||
|
||
handy shortcuts for navigating complex nested data structures in JavaScript | ||
|
||
## Wait Wat | ||
|
||
**Lenses** are tools that simplify traversal of complicated data structures, so named because they quickly **focus** on only the relevant parts of the data. They are extremely cool. You may have encountered them in [Haskell](https://www.haskell.org/) or the [Ramda.js](https://ramdajs.com/) functional programming toolkit for JavaScript, but each of those comes with a lot of baggage. In contrast, this module aims to provide a lightweight implementation and expose only a minimal surface API to your calling programs. | ||
|
||
# Quick Start | ||
|
||
Given a data object: | ||
|
||
```json | ||
{ | ||
"user_info": { | ||
"id": "11A237D57F76", | ||
"age": 27, | ||
"links": [ | ||
"https://www.example.com/document/8723498", | ||
"https://www.example.com/document/1767832" | ||
] | ||
} | ||
} | ||
``` | ||
|
||
Create a handy accessor function for reading from and writing to it: | ||
|
||
```javascript | ||
// create a lens function by supplying keys for | ||
// traversing the data object | ||
const first_link = lens(['user_info', 'links', 0]) | ||
|
||
// get the existing value | ||
first_link(user) // returns "https://www.example.com/document/8723498" | ||
|
||
// set a new value; will create objects and arrays | ||
// as necessary to satisfy traversal path without | ||
// requiring any existential checks | ||
first_link(user, "https://www.example.com/document/2378432") | ||
``` | ||
|
||
# Installation | ||
|
||
install the package from npm: | ||
|
||
```bash | ||
# install microlens package | ||
$ npm install microlens | ||
``` | ||
|
||
import the module into your JavaScript file: | ||
|
||
```javascript | ||
// as ES6 module | ||
import { lens } from 'microlens' | ||
|
||
// or | ||
|
||
// import as CommonJS module | ||
const lens = require('microlens').lens | ||
``` | ||
|
||
# But Why Tho | ||
|
||
Have you ever needed to work with a data structure -- maybe a response from a JSON API -- that includes a lot of extra stuff you don't need? | ||
|
||
Perhaps you feel the urge to quickly transform it before you pass it to the rest of your program: | ||
|
||
```javascript | ||
// fetch data to work with from somewhere else | ||
const data = retrieve(url) | ||
const clean = data.map(item => { | ||
// clean up and drop the unnecessary information | ||
}) | ||
// now proceed with the rest of your program | ||
program(clean) | ||
``` | ||
|
||
Unfortunately this means you are immediately working with a proprietary and idiosyncratic version of the data. | ||
|
||
Or maybe you leave the original data as is and use it by specifying object and array keys: | ||
|
||
```javascript | ||
// extract a URL from the data | ||
const firstUserHomepageUrl = data.users[0].links.homepage.url | ||
// render a link in your program using the URL | ||
renderLink(firstUserHomepageUrl) | ||
``` | ||
|
||
This approach is also problematic because it **couples your application logic to the data structure**, which means your program will break if anything changes with the incoming data. In the example above, the `renderLink()` function doesn't *really* care about the JSON structure, but it is likely to break because the structure of the data being stored hasn't really been decoupled from the application logic. This makes things brittle, and especially so if you have to look up the same nested accessors repeatedly. If you need to get that homepage URL more than once, you'll need to use that gross string of keys several times, each of which will separately break if the incoming data structure ever changes. | ||
|
||
There is a better way to do this! | ||
|
||
First, lenses **centralize** the accessor logic you need to traverse a data structure in a single function which is more easily maintained and reusable. | ||
|
||
```javascript | ||
// create a lens function to extract | ||
// the URL from the data | ||
const getUrl = lens(['users', 0, 'links', 'homepage', 'url']) | ||
// extract the URL and render a link in your program | ||
renderLink(getUrl(data)) | ||
``` | ||
|
||
Since lenses are functions, you can **compose** them. | ||
|
||
```javascript | ||
// create a lens function to extract the top user | ||
// from an input data set | ||
const topUser = lens(['users', 'ranked', 0]) | ||
// create a lens function to extract the country | ||
// from a data record representing a user | ||
const userCountry = lens(['location', 'address', 'country']) | ||
// compose lenses to get the country of the top ranked user in a data set | ||
const topUserCountry = input => userCountry(topUser(input))) | ||
``` | ||
|
||
That `topUserCountry()` function is equivalent to `['users']['ranked'][0]['location']['address']['country']`, but working with it is a lot more pleasant, and now your extraction logic can be used with different inputs. | ||
|
||
```javascript | ||
// get some top ranked user locations | ||
console.log(topUserCountry(tetrisPlayers)) // Russia maybe? Just a guess. | ||
console.log(topUserCountry(pizzaLovers)) // Italy maybe? Just a guess. | ||
``` | ||
|
||
Lenses work both ways, and can also be used as **setters**: | ||
|
||
```javascript | ||
// here comes a new challenger! | ||
topUserCountry(tetrisPlayers, newTetrisChampion) | ||
``` | ||
|
||
microlens assumes that you are confident in the traversal path across the data structure you specified with keys when creating your lens function, and will follow it in order to set values even if empty objects and arrays have to be created along the way in order to adhere to it. This means the data structures will be **consistent**. (If you are not confident in the traversal path specified by the keys, you may want to consider a different solution.) | ||
|
||
Because microlens creates objects and arrays as necessary during traversal for a set operation, you will **never have to use existential checks** when setting values that might not be set yet. For example, if directly using the keys corresponding to the `topUserCountry()` function in the earlier example, you'd need some extremely verbose defensive coding to make sure you don't end up with an undefined key error. If the top pizza lover moved to a new country, in order to defensively catch any undefined keys you'd need to do something like this (not a joke, this is the real syntax): | ||
|
||
```javascript | ||
if (pizzaLovers.users) { | ||
if (pizzaLovers.users.ranked) { | ||
if (pizzaLovers.users.ranked.length > 0) { | ||
if (pizzaLovers.users.ranked[0].location { | ||
if (pizzaLovers.users.ranked[0].location { | ||
if (pizzaLovers.users.ranked[0].location.address) { | ||
if (pizzaLovers.users.ranked[0].location.address.country) { | ||
return pizzaLovers.users.ranked[0].location.address.country | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
``` | ||
This is so obnoxious that [optional chaining](https://github.com/tc39/proposal-optional-chaining) was proposed as a change to the JavaScript language itself in order to help deal with it! | ||
In contrast, doing the same thing with a lens function is clear and concise: | ||
```javascript | ||
// you can even start with a completely empty root data object | ||
const pizzaLovers = {} | ||
// set new value with a lens | ||
topUserCountry(pizzaLovers, "Brazil") | ||
// all the necessary objects and arrays are automatically created | ||
pizzaLovers.users.ranked[0].location.address // {country: "Brazil"} | ||
``` | ||
# Creating A Lens | ||
To create a new lens function, just call `lens()` with one argument, which is an array of the keys you want to look up when getting or setting the value. | ||
```javascript | ||
// get or set the first favorite food listed | ||
// in a user object | ||
const favoriteFood = lens(['foods', 'favorites', 0]) | ||
|
||
// get or set the name of the third place winner | ||
// of the most recent annual contest | ||
const mostRecentThirdPlaceWinner = lens(['winners', 'years', 0, 2, 'name']) | ||
``` | ||
# Using Lenses | ||
To *get* a value from a data structure, just provide the data structure to the lens function as the first argument. | ||
```javascript | ||
// get a user's favorite food | ||
favoriteFood(user) // Pizza? Just a guess. | ||
``` | ||
To *set* a value inside a data structure, provide the data structure as the first argument and the new value as the second argument. | ||
```javascript | ||
// set a user's favorite food | ||
favoriteFood(user, "chicken tikka masala") | ||
``` | ||
The return value of a set operation is the original data structure with the new value updated. | ||
# Immutable Data | ||
Functional programming in JavaScript is delightful, but even though functional programming prefers immutable data structures, in JavaScript the data is often only treated as immutable *by convention*, because deep cloning of nested data structures is hard, `Object.freeze()` is shallow, and so on. Enforcing true immutability is beyond the scope of a lens library like this one, but if you have a solution for immutable data that works for your use case, you can *attach* it to any lenses you can create, in which case it will be run at the end after setting a value so as to return an immutable copy of the input data, however you've defined "immutable" in your project. | ||
To force a lens to return immutable data, provide the lens creation function with a second parameter, which is a function for taking input data and returning an immutable copy of it: | ||
```javascript | ||
// create an immutable copy by casting the data structure | ||
// to a string and then immediately parsing that string | ||
const immutable = data => JSON.parse(JSON.stringify(data) | ||
|
||
// supply the function to copy data to the lens creation function | ||
const favoriteFoodImmutable = lens(['foods', 'favorites', 0], immutable) | ||
``` | ||
Some of your options for enforcing immutability include: | ||
- `JSON.stringify()` to encode and parse data as a serialized string | ||
- `Array.prototype.slice()` for shallow copying of arrays | ||
- `_.clone()` from [Underscore](https://underscorejs.org/#clone) | ||
- `_.cloneDeep()` from [lodash](https://lodash.com/docs/4.17.10#cloneDeep) | ||
- [Immutable.js](https://github.com/facebook/immutable-js) | ||
- [seamless-immutable](https://github.com/rtfeldman/seamless-immutable) | ||
- [devalue](https://github.com/Rich-Harris/devalue) for a version of the JSON serialization trick that supports more JavaScript idiosyncrasies | ||
Good luck! | ||
# Composing Lenses | ||
Lenses are just functions so you can compose them however you like! However, there are some tricks and edge cases to consider... | ||
## Composing Manually | ||
*inline, on the fly, etc...* | ||
### Getters | ||
If you just need a composed *getter*, there's really no reason not to just whip up a quick lil inline thing with a one-line arrow function: | ||
```javascript | ||
// get the country of residence of the top | ||
// ranked player of the top selling video game | ||
const topGameTopPlayerCountry = data => country(topPlayer(topGame(data))) | ||
``` | ||
Note that here the *broadest* lens function which operates at the *outer* levels of the data structure is located at the *inside* of the composition. | ||
### Setters | ||
For a composed *setter*, note that you only need to pass the value once, at the outermost layer of the composition, because the rest of the functions are just *looking up* the location in the data structure at which you want to write that value. Don't worry, it's still pretty short: | ||
```javascript | ||
// get the country of residence of the top | ||
// ranked player of the top selling video game | ||
const topGameTopPlayerCountry = (data, value) => country(topPlayer(topGame(data)), value) | ||
``` | ||
The usual return value of a lens function set operation is to return the data structure with the new value. If you are whipping up lil inline compositions and need to ensure that behavior remains consistent, you can use parentheses and a comma to explicitly return the input data structure, and it will still probably fit on one line: | ||
```javascript | ||
// get the country of residence of the top | ||
// ranked player of the top selling video game | ||
// and return the data structure from the | ||
// composed lens as per usual lens behavior | ||
const topGameTopPlayerCountry = (data, value) => (country(topPlayer(topGame(data)), value), structure) | ||
``` | ||
### Immutability | ||
If you need immutable data returned from your compositions, you'll probably want to use the composition helper function instead of throwing together one-liners on the fly. | ||
### Composition Helper Function | ||
Inline lens composition works just fine as described above, but microlens also supplies an optional composition helper function which handles all the edge cases *and* enforces any immutability you have configured. To use it, just pass it an array of lenses. The array should read from left to right, with the most general lens that operates first listed first in the array. Your composed lens will be immutable if that broadest lens listed in the array is immutable (`topGame()`, in this example). | ||
```javascript | ||
// import the composition helper function | ||
import { compose } from 'microlens' | ||
// define an array of lenses | ||
const lenses = [topGame, topPlayer, country] | ||
// compose the new lens | ||
const composition = compose(lenses) |