javascriptrecursionfunctional-programmingramda.jscurrying

Ramda recursive curried function throws Maximum call stack size exceeded


I try to understand ramda a bit better and why in the following example mapTreeNode2 does not work exactly like mapTreeNode.

In a new project my state is represented in a huge tree and I want to make sure I get the fundamentals of immutable tree manipulation with ramda right.

const state = {
  id: 1,
  name: "first",
  children: [{
    id: 2,
    name: "second",
    children: []
  }, {
    id: 3,
    name: "third",
    children: [{
      id: 4,
      name: "fourth",
      children: []
    }]
  }, {
    id: 5,
    name: "fifth",
    children: [{
      id: 6,
      name: "sixth",
      children: []
    }]
  }]
};

 
const hasChildren = (node) => {
  var x = 
     (typeof node === 'object')
        && (typeof node.children !== 'undefined')
        && (node.children.length > 0);

  // console.log(x, node) 
  
  return x
} 

// This works
const mapTreeNode = curry( (mapFn, treeNode) => {
        const newTreeNode = mapFn(treeNode);
        if (!hasChildren(treeNode)) {
            return newTreeNode;
        }
        return evolve({children: map(mapTreeNode(mapFn))}, newTreeNode)
    })

// This doesn't: Maximum call stack size exceeded
const mapTreeNode2 = curry( (mapFn) => ifElse(
  hasChildren,
  compose(
    evolve({children: map(mapTreeNode2(doStuff))}), 
    mapFn,
  ), 
  mapFn
))
 

const doStuff = ifElse(propEq('id', 6), evolve({name: concat("x")}), identity)

// This works
mapTreeNode(doStuff)(state)

// This doesn't: Maximum call stack size exceeded
mapTreeNode2(doStuff)(state)

Solution

  • You don't need any currying here since the function has only a single parameter anyway. But that's not the issue, it doesn't work without curry either. To see why, let's refactor the function a bit:

    const mapTreeNode2 = (mapFn) => {
      const mapper = mapTreeNode2(mapFn);
      const evolver = evolve({children: map(mapper)});
      const then = compose(evolver, mapFn);
      return ifElse(hasChildren, then, mapFn);
    };
    

    Right there in the first line we have an unbounded recursion… The problem is that JS is using strict evaluation, so the code of the then branch still gets evaluated with ifElse, unlike a proper if/else statement or conditional operator expression.

    To avoid that, you can try

    const mapTreeNode2 = (mapFn) => {
      const evolver = evolve({children: map(mapper)});
      const then = compose(evolver, mapFn);
      const mapper = ifElse(hasChildren, then, mapFn);
      return mapper;
    };
    

    but again that won't work without lazy evaluation, accessing mapper in the temporal dead zone. The only way to resolve this circular reference between variables in JS is to use a function and utilise hoisting, e.g.

    const mapTreeNode2 = (mapFn) => {
      const mapper = (treeNode) => {
        const evolver = evolve({children: map(mapper)});
    //                                        ^^^^^^ now the reference works
        const then = (node) => compose(evolver, mapFn);
        return ifElse(hasChildren, then, mapFn)(treeNode);
      };
      return mapper;
    };