Search code examples
javascriptpiperefactoringramda.js

Ramda JS - Pass optional argument to the second function of a pipe


I am trying to refactor this code:

async function getUserDataByUsername(username, cached = true) {
  const usernameRef = firestore
    .collection("usernames")
    .doc(username.toLowerCase());

  const usernameDoc = await usernameRef.get();

  if (!usernameDoc.exists) {
    throw userErrors.userNotFound();
  }

  const { userId } = usernameDoc.data();

  return memoizedGetUserData(userId, cached);
}

For that, I have thought to split it in smaller parts, as follows:

function memoizedGetUserData(userId, cached = true) { 
  ... Fetching from LRU or DB ... 
}

async function getUserId(username) {
  const usernameRef = firestore
    .collection("usernames")
    .doc(username.toLowerCase());

  const usernameDoc = await usernameRef.get();

  if (!usernameDoc.exists) {
    throw userErrors.userNotFound();
  }

  const { userId } = usernameDoc.data();

  return userId;
}

async function getUserDataByUsername(username, cached = true) {
  const userId = await getUserId(username);

  return memoizedGetUserData(userId, cached);
}

Now, I want to apply Ramda to this module. I have never used this library before, but I have read that it is really cool, and makes the code easier to understand with some of its utilities.

What I am trying is to refactor the original method using the pipeline style, as follows:

import R from "ramda";

...

const getUserDataByUsername = R.pipeP(getUserId, memoizedGetUserData);

But... how can I pass the second optional parameter "cached", only to the second argument of my pipe??


Solution

  • I think we need to start with this:

    Now, I want to apply Ramda to this module. I have never used this library before, but I have read that it is really cool, and makes the code easier to understand with some of its utilities.

    I'm afraid that is putting things in the wrong order. Ramda (disclaimer: I'm one of its authors) is designed for a simple purpose: making it simpler to write in an Functional Programming (FP) manner. It is not a general-purpose library in the mold of Underscore or lodash. While it can make your code easier to understand (and I for one do think it's cool), if you're not looking to write in FP style, then adding Ramda will likely be counterproductive.

    With that out of the way, let's assume you do want to start moving toward FP, and Ramda will be the first step on that journey. Then lets look at the code. The main function is something like

    const getUserDataByUsername = (username, cached = true) => { /* ... */ }
    

    Ramda is very invested in curried functions that are easy to compose into pipelines. That means that optional arguments are almost impossible to deal with well. Usually in Ramda, when we work with defaulted optional arguments, we create one function that requires these values, then build another one atop it that partially applies the default values. It might look like this:

    const userByName = (cached) => (username) => { /* ... */ }
    const getUserDataByUsername = userByName (true)
    

    With Ramda's currying, we might also write that as

    const userByName = curry ((cached, username) => { /* ... */ })
    

    We now have two functions, userByName is the more generic, but it requires you to supply the cached variable. getUserDataByUsername is simpler, requiring only the username.

    In order to do this, though, we will also need to change memoizedGetUserData, which has a similar (userId, cached = true) => { /* ... */ } stucture. Again we could manually curry it like this:

    const memoizedGetUserData = (cached) => (userId) => { /* ... */ }
    

    or use Ramda's currying:

    const memoizedGetUserData = curry ((cached, userId) => { /* ... */ })
    

    Note that in both these cases, we have moved what was a defaulted optional parameter from the end to the beginning of the signature. Ramda's design around currying and partial application means that we always order the parameters from least likely to change to most likely to change. If we're defaulting a parameter, we clearly believe it's less likely to change, so it should appear earlier. Some people describe this as "data-last", but I think it's a bit more subtle than that.

    Let's now implement that main function. The original looked like this:

    async function getUserDataByUsername(username, cached = true) {
      const userId = await getUserId(username);
    
      return memoizedGetUserData(userId, cached);
    }
    

    Here's how I might first look to refactor it:

    const userByName = (cached) => (username) => getUserId (username) .then (memoizedGetUserData (cached))
    

    That might be fine on its own. There's only two steps. A pipeline might be unnecessary, but for practice, let's look at how we might write a pipeline for this. First off, note that pipeP and composeP, the specific Promise pipelining functions, have been deprecated for some time in favor of the more generic pipeWith/composeWith functions paired with andThen, and in fact, they have been removed entirely from the just-released version.

    So, using pipeWith it could look like this:

    const userByName = (cached) => pipeWith (andThen, [getUserId, memoizedGetUserData (cached)])
    

    This should behave exactly the same as the above.

    I personally would probably stop here. But many people like their functional programs to be mostly point-free. I'm not sure there's any great reason here, but if we wanted to do it, we could go one step further, and write this as

    const userByName = pipe (memoizedGetUserData, andThen, flip (o) (getUserId))
    

    I don't find this version very compelling, and would choose one of the first two, but any of them should work.

    You can see this in action, with dummy versions of some of your dependencies by expanding this snippet:

    // dummy
    const memoizedGetUserData = curry ((cached, userId) => ({id: 123, first: 'Fred', last: 'Flintstone'}))
    
    async function getUserId(username) {
      const usernameRef = firestore
        .collection("usernames")
        .doc(username.toLowerCase());
    
      const usernameDoc = await usernameRef.get();
    
      if (!usernameDoc.exists) {
        throw userErrors.userNotFound();
      }
    
      const { userId } = usernameDoc.data();
    
      return userId;
    }
    
    const userByName = (cached) => pipeWith (andThen, [getUserId, memoizedGetUserData (cached)])
    // or
    // const userByName = (cached) => (username) => getUserId (username) .then (memoizedGetUserData (cached))
    // or
    // const userByName = compose (flip (o) (getUserId), andThen, memoizedGetUserData)
    
    const getUserDataByUsername = userByName (true)
    
    getUserDataByUsername ('fred') 
      .then (console .log) 
      .catch (console .warn)
    <script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.28.0/ramda.min.js"></script>
    <script> 
    const {curry, andThen, pipeWith} = R
    // Dummy versions
    const firestore = {collection: (name) => ({doc: () => ({get: () => Promise.resolve ({exists: () => true, data: () => ({id: 123, first: 'Fred', last: 'Flintstone'})})})})}
    const userErrors = {userNotFound: () => 'oops'}
    </script>