1

I have a bunch of regular utility functions. I want to convert those functions to ones that accept arguments wrapped in functions (to introduce a side-effect when a value is used).

// Some utility function:
const pick = (takeLeft, left, right) => 
  takeLeft ? left : right;

// Some wrapper with a side-effect
const Watched = x => () => {
  console.log(`Value ${x} is used`);
  return x;
};

// I want this to log:
//   Value true is used
//   Value L is used
const myPick = runWithWatchedValues(
  pick,
  Watched(true), Watched("L"), Watched("R")
);

I’m looking for help implementing runWithWatchedValues, or for somebody to explain me why it can’t be done.

Attempts

The problem with unwrapping the values before calling the inner function:

// An incorrect attempt: (also logs "R")
const runWithWatchableValues = (f, ...args) => 
  f(...args.map(w => w()));

I tested if calling the function with apply and some special getters would work, but it turns out this does exactly the same .

// Another incorrect attempt: (also logs "R")  
const runWithWatchableValues = (f, ...watched) => {
  const args = watched.reduce(
    (acc, get, i) => Object.defineProperty(acc, i, { get }),
    []
  );

  return f.apply(null, args);
};

My current solution is to manually rewrite utility functions. I.e.:

const pick = (takeLeft, left, right) => 
  takeLeft ? left : right;

const pickW = (takeLeft, left, right) => 
  takeLeft() ? left() : right();

// Correctly logs true and L
pickW(Watched(true), Watched("L"), Watched("R"));

I’d rather not maintain my own library of utility functions when there are well documented and well maintained libraries like ramda or lodash…

The question(s)

I’m starting to feel like the thing I want just is just not something the language can do… But I hope I’m wrong!

  • Is it theoretically possible to write runWithWatchableValues and get the desired result?
    • If yes, how?
    • If no, is there another automated way (Babel/build steps?) you can think of to prevent having to manually rewrite pick to work with wrapped values?

“This is an x/y problem!”

It might be, but I wanted to keep it simple. Here’s what I’m really doing (using knockout.js):

const pick = (takeLeft, left, right) =>
  takeLeft ? left : right;

const takeLeft = ko.observable(true);
const left = ko.observable("L");
const right = ko.observable("R");

// BROKEN: The easy, but wrong implementation:
const myPick = ko.pureComputed(
  () => pick(takeLeft(), left(), right())
);

console.log(
  "Naive approach:",
  myPick(),                     // Right: "L"
  myPick.getDependenciesCount() // Wrong: 3
);

// The dependency on `right` will mean that updating
// it will cause myPick to re-evaluate, even though we
// already know its return value won't change.


// FIXED: The manual fix:
const pickObs = (takeLeft, left, right) =>
  takeLeft() ? left() : right();

const myCorrectPick = ko.pureComputed(
  () => pickObs(takeLeft, left, right)
);

console.log(
  "With manual rewrite:",
  myCorrectPick(),                     // Right: "L"
  myCorrectPick.getDependenciesCount() // Right: 2
);

// Changing `right` doesn't do anything. Only once `takeLeft`
// is set to `false`, a dependency on `right` will be created
// (and the dependency on `left` will be removed).
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>
user3297291
  • 22,592
  • 4
  • 29
  • 45
  • You could iterate over your arguments, check if they're functions, and if so, replace them with the result of calling them. – Cerbrus Jul 31 '19 at 12:46
  • "*I’d rather not maintain my own library of utility functions when there are well documented and well maintained libraries like ramda or lodash*" - yes, just use [`Ramda.ifElse`](https://ramdajs.com/docs/#ifElse). – Bergi Jul 31 '19 at 12:53
  • @Bergi that's what I wanted to do, but when heavily relying on knockout's computed I ended up rewriting most of them. In the case of `ifElse` (which inspired my `pick` example), it was to prevent an incorrect dependency on the `else` clause... – user3297291 Jul 31 '19 at 12:56
  • @Cerbrus My real issue isn't with mixed types of inputs, but with the lazily unwrapping of the inputs. I think what you're describing is adding a `typeof x` option in the `map` method of my first attempt, which would still incorrectly log me an `R`. – user3297291 Jul 31 '19 at 12:57

2 Answers2

1

Is it theoretically possible to write runWithWatchableValues and get the desired result?

No. Arguments are not passed lazily in JavaScript. You would need to strictly evaluate the functions before passing the values to the wrapped function, and that's what you are trying to avoid.

Is there another automated way (Babel/build steps?) you can think of to prevent having to manually rewrite pick to work with wrapped values?

Sure, you can write your own compiler that does that (it seems relatively easy), but I doubt there is an existing babel plugin that does this. Lazy evaluation is not useful that often, most functions use all their arguments in any case.

Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • Thanks for understanding and clarifying. Part of me was kind of still hoping for some sketchy `with` / regex based approach (which I probably then wouldn't dare to use in production code... Might look in to creating a custom babel plugin; will post an answer if I ever get around to it. – user3297291 Aug 02 '19 at 12:29
  • @user3297291 You can do some hacky things with [`with` and getters](https://stackoverflow.com/q/48270127/1048572), but you would need to inject that into the function's code - not possible using a `runWithWatchableValues` wrapper. – Bergi Aug 02 '19 at 13:14
0

You can handle a form of "lazy evaluation" in your pick function, if you can change how data is passed to that function:

function pick(takeLeft, left, right){
  if(typeof takeLeft === "function"){
    takeLeft = takeLeft();
  }

  let choice = takeLeft ? left : right;

  if(typeof choice === "function"){
    choice = choice();
  }

  return choice;
}

const p1 = pick(
  () => true,
  () => { console.log("Left is called!"); return "left"; },
  () => { console.log("Right is called!"); return "right"; });

const p2 = pick(
  false,
  "left",
  "right");

console.log(01, p2)

As you can see, the 2nd parameter isn't called if you tell it to get the left one, but you can still pass normal variables.
So, if you want something to be evaluated lazily, only if it's chosen, pass it as a callback instead of a normal value.

Cerbrus
  • 70,800
  • 18
  • 132
  • 147
  • Thanks for answering. Unfortunately, it's not the "how do I rewrite existing functions to accept my parameters" that's bothering me (in practice, it's replacing every `x` by `ko.unwrap(x)`). I'm trying to fix that I've ended up writing my own `propOr`, `ifElse` and many more utility methods that are inferior to ramda's implementations. – user3297291 Jul 31 '19 at 14:00