is this a spot for functional lenses in javascript?

2.1k views Asked by At

Playing around with point-free style javascript for fun.

Say I am coding the video game Diablo, and I am modeling enemies using complex nested types like this but deeper and more complicated:

{ name: "badguy1", stats: { health: 10: strength: 42 }, pos: {x: 100, y: 101 } }

So I have a list of all my enemies. I want to do damage to all the enemies within a certain radius

function isInRange(radius, point) { return point.x^2 + point.y^2 >= radius^2; }
function fireDamage(health) { return health - 10; }    
var newEnemies = enemies.filter(isInRange).map(fireDamage);

this of course doesn't type check - my combinators take primitives, so i need to map and filter "down another level". I don't want to obscure the filter/map business logic pipeline. I know lenses can help me but lets say I am in a browser, as this is of course trivial with mutable structures. How do I do it?

2

There are 2 answers

3
Gabriella Gonzalez On

Read my article on lenses. It answers your question exactly the way you worded it. Seriously, I'm not even joking. Here's a code snippet from my post:

fireBreath :: Point -> StateT Game IO ()
fireBreath target = do
    lift $ putStrLn "*rawr*"
    units.traversed.(around target 1.0).health -= 3
0
wpcarro On

Is your question is about how to use lenses in Javascript? If so, I may be able to help. Have you checked out the Ramda.js library? It's a terrific way to write functional JS. Let's start by looking at your enemy model:

/* -- data model -- */
let enemyModel = {
  name: "badguy1",
  stats: {
    health: 10,
    strength: 42
  },
  pos: {
    x: 100,
    y: 101
  }
};

Lens: In order to construct a lens you need a getter method and a setter method for your specific object -- in your case the "enemy". Here's how you could construct those by hand.

Method 1: Create your own getters and setters

const getHealth = path(['stats', 'health']);
const setHealth = assocPath(['stats', 'health']);
const healthLens = lens(getHealth, setHealth);

Method 2: Ramda's expedient convenience-lens for Objects

const healthLens = lensPath(['stats', 'health']);

Once you've created the lens, it's time to use it. Ramda offers 3 functions for using lenses: view(..), set(..), and over(..).

view(healthLens)(enemyModel); // 10
set(healthLens, 15)(enemyModel); // changes health from 10 to 15
over(healthLens, fireDamage)(enemyModel); // reduces enemyModel's health property by 10

Since you're applying the fireDamage(..) function to an enemy's health, you'll want to use over(..). Also, since your position coordinates are nested within the enemyModel, you're going to want to use a lens to access those as well. Let's create one and refactor isInRange(..) while we're at it.

As a reference, here's the origin fn:

// NOTE: not sure if this works as you intended it to...

function isInRange(radius, point) {
  return point.x^2 + point.y^2 >= radius^2; // maybe try Math.pow(..)
}

Here's a functional approach:

/* -- helper functions -- */
const square = x => x * x;
const gteRadSquared = radius => flip(gte)(square(radius));
let sumPointSquared = point => converge(
  add,
  [compose(square, prop('x')), 
   compose(square, prop('y'))]
)(point);
sumPointSquared = curry(sumPointSquared); // allows for "partial application" of fn arguments

/* -- refactored fn -- */
let isInRange = (radius, point) => compose(
  gteRadSquared(radius),
  sumPointSquared
)(point);
isInRange = curry(isInRange);

Here's what that would look like when dealing with a collection of enemyModels:

/* -- lenses -- */
const xLens = lensPath(['pos', 'x']);
const yLens = lensPath(['pos', 'y']);
const ptLens = lens(prop('pos'), assoc('pos'));

// since idk where 'radius' is coming from I'll hard-code it
let radius = 12;

const filterInRange = rad => filter(
  over(ptLens, isInRange(rad))  // using 'ptLens' bc isInRange(..) takes 'radius' and a 'point'
);
const mapFireDamage = map(
  over(healthLens, fireDamage)  // using 'healthLens' bc fireDamage(..) takes 'health'
);

let newEnemies = compose(
  mapFireDamage,
  filterInRange(radius)
)(enemies);

I hope this helps illustrate how useful lenses can be. While there are many helper functions, I think the end piece of code is super semantic!

Lastly, I'm just flooding my scope with these functions from Ramda to make this example more readable. I'm using ES6 deconstruction to accomplish this. Here's how:

const {
  add,
  assocPath,
  compose,
  converge,
  curry,
  filter,
  flip,
  gte,
  lens,
  lensPath,
  map,
  over,
  set,
  path,
  prop,
  view
} = R;

// code goes below...

Try it out in jsBin! They offer Ramda support.