2

I am learning rxjs and am in the process of converting some of my old-school array manipulation functions to use rxjs. Here is one that groups items in an array:

interface FilmGroup {
    key: string;
    films: Film[]
}

private groupFilms(items: Film[]): Observable<FilmGroup[]> {

    return from(items)   
    .pipe(
        groupBy(item => item.name),

        mergeMap(group$ => group$
            .pipe(
                toArray(),
                map(films => ({ key: group$.key, films})  <==== Not a pure function!
            )
        )),
        
        toArray()
    );
}

When subscribed to this function produces a result of the form

[
  {
    "key": "a",
    "films": [
      {
        "id": 1,
        "name": "a"
      },
      {
        "id": 2,
        "name": "a"
      }
    ]
  },
  {
    "key": "b",
    "films": [
      {
        "id": 3,
        "name": "b"
      }
    ]
  },
  etc...
]

This works as expected, but I am concerned about the way the function is written. The rxjs book I use strongly advises only using pure functions when writing rxjs code. The projection for map( films => ...) is not a pure function since it pulls in the group$.key value from an outer scope, not via the parameters.

Is this an OK thing to do, or am I approaching this the wrong way?

Paul D
  • 601
  • 7
  • 13
  • You could *convert* it to a pure function by adding it as a parameter. And you can make this very easily reusable via currying. The question is - is it worth it? A pure function you can extract elsewhere but in many cases we don't really care to do that - we have little lambda/arrow functions embedded in our code that don't bother anybody. – VLAZ Feb 02 '21 at 12:40
  • VLAZ - how would I add an extra parameter? The docs for map say the projection function takes 2 parameters, value and index. – Paul D Feb 02 '21 at 12:45
  • We can reason that the function is *effectively* pure. Even though `$group` is pulled into the closure, it ain't gonna change over the lifetime of that function, thereby making it (effectively) invariant. – spender Feb 02 '21 at 12:46
  • I'm a big fan of pure functions and constant values. I do this all the time. I don't see it as problematic unless someone unversed in functional purity gets their hands on your code. The real problem here is that the underlying language allows mutation. In functional languages that disallow mutation, this kind of q is moot. – spender Feb 02 '21 at 12:52
  • So perhaps my understanding of a pure function is wrong. It is that pure functions must only access mutable values via its parameters? A pure function can pull in immutable values from anywhere? – Paul D Feb 02 '21 at 12:57
  • "A pure function can pull in immutable values from anywhere" Yes. If the value is immutable then it is invariant. The output of the function can't be affected by actions that occur outside of its own scope if it only closes over immutable values. Really functional purity and immutability come hand-in-hand, but IMO, it's OK to compromise slightly in the face of JS's elasticity. – spender Feb 02 '21 at 13:08
  • @spender - in my example I stripped out a lot of detail for clarity, but in reality all the interfaces I create are marked-up as readonly, and the functions return readonly results. I did this primarily to allow onPush change detection in Angular, but it seems to fit in perfectly with rxjs too. – Paul D Feb 02 '21 at 15:13

1 Answers1

3

Let's look at some theory first.

Here is a curious property of functions - an impure function that uses some outside data can be converted to a pure function by making the data a parameter.

Let's start with an impure function:

const getSomeData = () => 
  Math.random() < 0.5
    ? "foo"
    : "hello"
  
const fn = thing => 
  `${getSomeData()} ${thing}`;

console.log(fn("world"));
console.log(fn("world"));
console.log(fn("world"));
console.log(fn("world"));

Right now fn depends on someData that we cannot predict. It's impure. Repeat application will not yield the same result.

If we fold the data as a parameter, we can achieve repeatable and pure results:

const getSomeData = () => 
  Math.random() < 0.5
    ? "foo"
    : "hello"
  
const fn = (prefix, thing) => 
  `${prefix} ${thing}`;

const data = getSomeData();

console.log(fn(data, "world"));
console.log(fn(data, "world"));
console.log(fn(data, "world"));
console.log(fn(data, "world"));

Any call to fn with the same parameters now yields the same results.

However, we've changed the arity of the function and made it binary (takes two arguments, rather than works with base 2 numbers). It makes using it awkward as a unary function is more useful in some cases like

const arr = ["Alice", "Bob", "Carol"];

const fn = (prefix, thing) => 
  `${prefix} ${thing}`;
  
console.log(
  arr.map(name => fn("hello", name))
)

It's usable but awkward, as we still need another function on top of it.

Here is where a useful tool called currying comes in - any function with multiple arguments can be converted to a series of unary functions:

const arr = ["Alice", "Bob", "Carol"];

const fn = prefix => thing => 
  `${prefix} ${thing}`;

console.log(
  arr.map(fn("hello"))
)

The this curried version of fn is still pure because repeat calls with the same parameters still yield the same result. You can even capture each application in a variable like const g = fn("hello") and call g("world") which is identical to fn("hello")("world"). As can be seen above, this is handy when passing functions to something like .map() when you need multiple arguments but only the last one would vary.

This quick into into theory and it's application was needed because now we need to think about your case - Is

group$ => group$
    .pipe(
        toArray(),
        map(films => ({ key: group$.key, films})
)

really dissimilar to what we did with the curried function above? Currying works because the inner function have access to variables outside of themselves. However, this is not going to be impure, because the outer parameters are not going to change. So, each inner function is still pure because it maintains its referential transparency - a function call can be replaced with its result. Let's revisit:

  const a = fn("hello")
  g("world") === fn("hello")("world")
//^              ^^^^^^^^^^^
//these two can be freely substituted to one another

So, with this context in mind films => ({ key: group$.key, films}) can still be considered pure, since group$.key is never going to change. Repeat executions of this function will yield the exact same results again. Therefore, I wouldn't personally worry about it.

Still, just to round things off, this can be abstracted away as a pure curried function:

const makeFilmObj = key => films =>
    ({ key, films });

/* ... */


mergeMap(group$ => group$
    .pipe(
        toArray(),
        map(makeFilmObj(group$.key))
)),

in this case, it's valid refactoring but it seems like a bit excessive. This might be a good approach if the makeFilmObj was perhaps to be reused later and/or perhaps unit tested as it's now dissociates from its context.

VLAZ
  • 26,331
  • 9
  • 49
  • 67
  • Thanks VLAZ. I've got a much better understanding now. I've read about currying before but never really saw the use for it. So currying is the way I can get the map function to act as though it takes two (or more) parameters. Nice! – Paul D Feb 02 '21 at 14:19
  • PS I agree that this approach is perhaps overkill in my situation, but it is good to understand what can be done to make the code pure, and why it does not matter that much (in this case) if we don't. – Paul D Feb 02 '21 at 14:22
  • 1. Yes, currying it converting a **n**-ary function into an **n** number of unary functions. So, you can also do `x => y => z => x + y + z` as a curried trinary. In some cases, it makes sense to curry for simplicity. 2. I'm glad you found it helpful. I aimed to explain currying and purity in enough details for you to be able to evaluate future cases. – VLAZ Feb 02 '21 at 14:27
  • @PaulD but even in unary function that returns a function, the inner function closes over parameters from the outer function, so to some degree, we've come full circle :) – spender Feb 02 '21 at 14:30
  • @spender - so does the currying solution provide a pure function to map() or not? I am out of my depth again now. – Paul D Feb 02 '21 at 14:45
  • 1
    @PaulD I say yes. Functional purists might say no, but in js, without inbuilt immutability, by adopting a non mutating style, we make a best effort towards functional purity (that can be broken by non-observers of the no-mutation convention) – spender Feb 02 '21 at 15:01
  • 1
    @PaulD For your example, the curried function `makeFilmObj` is just as pure as your original solution. This is what @spender meant when he said "we've come full circle." In the end, the two approaches look the same from a functional point of view. A fixed-parameter is actually just a value assigned in a function's closure. They look a bit different, but they're really the same thing. – Mrk Sef Feb 02 '21 at 17:32
  • 1
    Thanks all, I have a much better understanding now. Stack overflow is great. – Paul D Feb 02 '21 at 18:57