BrianDamaged.org

AOL keyword: lame

reDECORATING my Javascript

2019-01-20

Update: I’m writing a library that codifies the ideas in this post:

https://github.com/briandamaged/js-redecor8


Have you ever found yourself writing repetitive code such as the following?

const BASE = "https://fakeapi.briandamaged.org";

async function fetchUser(username) {
  try {
    return await axios.get(`${BASE}/users/${username}`);
  } catch(err) {
    console.log("Error while fetching User", err);
    throw err;
  }
}

async function fetchCat(id) {
  try {
    return await axios.get(`${BASE}/cats/${id}`);
  } catch(err) {
    console.log("Error while fetching Cat", err);
    throw err;
  }
}

async function fetchDog(id) {
  try {
    return await axios.get(`${BASE}/dogs/${id}`);
  } catch(err) {
    console.log("Error while fetching Dog", err);
    throw err;
  }
}

// Etc. etc. etc.

Notice that all of these functions have nearly identical try/catch logic. Wouldn’t it be great if there was a way to magically extract this “logging” aspect of the code and just do something like this instead?

@LogsErrors("Error while fetching User")
async function fetchUser(username) {
  return await axios.get(`${BASE}/users/${username}`);
}

@LogsErrors("Error while fetching Cat")
async function fetchCat(id) {
  return await axios.get(`${BASE}/cats/${id}`);
}

@LogsErrors("Error while fetching Dog")
async function fetchDog(id) {
  return await axios.get(`${BASE}/dogs/${id}`);
}

Well, guess what? That’s exactly what Javascript Decorators allow you to do! Here’s some great tutorials to help you get started:

BUT: before you get too excited, you should be aware of a few caveats:

  • Language-level support for Javascript decorators is still just a proposal. You can begin using them today via Babel Transpilation, but it’s still possible that the approach could fundamentally change in the future.
  • Javascript decorator support seems to focus primarily on class, method, and property decoration. The underlying mechanisms do not appear to be applicable to raw functions.

Well, darn it! I tend to use functional programming paradigms in my code, which means I write a lot of raw functions! I guess I’m just out of luck…

Falling down in despair

Just kidding! Sure, we might not be able to take advantage of the decorator syntax, but it’s fairly straightforward to recreate the same basic idea for raw functions. We just need to write a function that accepts a function as input and returns a function as its output. Erm… I mean like this:

f(FUNCTION) -> FUNCTION

Hmm… perhaps that still isn’t very clear. So, let’s walk through a concrete example. We’d like to extract the “logging” aspect out of the aforementioned fetchUser(...) function. We can do this as follows:

// This is a decorator that accepts the `next` function
// as an input.  It then wraps the `next` function with
// another function to introduce the logging behavior.
const logsUserErrors = (
  (next)=>
    async function(username) {
      try {
        return await next.call(this, username);
      } catch(err) {
        console.log("Error while fetching User", err);
        throw err;
      }
    }
);


// Now we can apply the `logsUserErrors` decorator
// to our simple function.  The end result will be
// a composition of functions that accomplishes the
// desired behaviors.
const fetchUser = logsUserErrors(
  async function _fetchUser(username) {
    return await axios.get(`${BASE}/users/${username}`);
  }
)

Eeeeeeek! I thought decorators were supposed to make the code more readable! But, this is definitely less readable! What the hell happened?

The problem is that the logsUserErrors(..) decorator is still too tightly coupled with the underlying _fetchUser(..) function. Specifically:

  • It expects next to be a function that accepts a username as a parameter. We need to generalize this so that next can be any function.
  • The error message text assumes that the error is somehow related to fetching Users. We need to make this detail configurable.

So, let’s take another crack at this:

// `LogsErrors` is a Factory function.  It accepts
// a `message` as a parameter and uses this to
// construct a customized Decorator function.
const LogsErrors = (
  (message)=>
    (next)=>
      async function(...args) {
        try {
          return await next.apply(this, args);
        } catch(err) {
          console.log(message, err);
          throw err;
        }
      }
);


// Create the decorator
const logsUserErrors = LogsErrors(
  "Error while fetching User"
);


// Apply the decorator
const fetchUser = logsUserErrors(
  async function _fetchUser(username) {
    return await axios.get(`${BASE}/users/${username}`);
  }
)

As you can see, this new implementation addresses both of our previous problems:

  • The decorator no longer makes any assumptions about the underlying function’s arguments. Instead, it just forwards ...args to the underlying function.
  • The outer factory function allows the decorator’s message to be customized.

Hooray! Now we can apply our decorator to each of our functions!

const fetchUser = LogsErrors("Error while fetching User")(
  async function _fetchUser(username) {
    return await axios.get(`${BASE}/users/${username}`);
  }
)


const fetchCat = LogsErrors("Error while fetching Cat")(
  async function _fetchCat(id) {
    return await axios.get(`${BASE}/cats/${id}`);
  }
)


const fetchDog = LogsErrors("Error while fetching Dog")(
  async function _fetchDog(id) {
    return await axios.get(`${BASE}/dogs/${id}`);
  }
)

Wooo! Sure, it’s not quite as elegant as the @ syntax, but it gets the job done!

Of course, the project lead has been asking us to introduce all sorts of fancy behaviors into our functions for the last several months. So, let’s finally fulfill their wish!

const fetchUser = (
  NotifiesOnFailure(["admin@briandamaged.org"])(
    LogsErrors("Error while fetching User")(
      MeasuresTransaction("fetch_user")(
        async function _fetchUser(username) {
          return await axios.get(`${BASE}/users/${username}`);
        }
      )
    )
  )
);

Umm… is it just me, or is the nesting making it a bit difficult to read the code again?

Dang it

Sigh… it is. It would be great if there was some way to combine all of the decorators without also introducing a bunch of nesting. If there was, then we’d be able to write the code like this instead:

// Here's the function we want to decorate.
async function _fetchUser(username) {
  return await axios.get(`${BASE}/users/${username}`);
}

// Create a new decorator that combines all of the
// decorators we want to use.
const decorate = compose([
  NotifiesOnFailure(["admin@briandamaged.org"]),
  LogsErrors("Error while fetching User"),
  MeasuresTransaction("fetch_user"),
]);

// Now, apply this combined decorator
const fetchUser = decorate(_fetchUser);

Wow! That’s a lot more readable! What are we waiting for? Let’s write that compose(..) function! This function needs to accept a list of decorators as an input. It will then return a new decorator that applies these decorators in the correct order. Something like this:

const compose = (
  (decorators)=>
    (next)=>
      Array
        .from(decorators)
        .reverse()
        .reduce((_next, d)=> d(_next), next)
);

Huzzah! Now we have a way to easily decorate raw functions without introducing a ton of nesting!

Let’s do a quick recap:

  1. A Decorator is a function that accepts a function as an input and returns a function as an output. Decorators are a useful way to introduce behaviors around lower-level functions.
decorate(FUNCTION) -> FUNCTION
  1. A Decorator Factory is a function that constructs a decorator. These are useful when you want to customize the behavior of the decorator before applying it to the lower-level function.
factory(...args) -> DECORATOR
  1. Decorators can be combined via a composition operation. This is useful when you want to apply several decorators without also introducing a ton of nesting in the code.
compose([DECORATOR, ...]) -> DECORATOR

And, that’s it! Now, go forth and decorate all of your code!