This beast knows how to Grunt. CC BY-SA 3.0 by Nicor.

For approximately 18 months, most of my time working at Opower was spent writing grunt tasks, wiring them together, and using them in other projects. It took me some time to figure out how to write good tasks, so I’d like to save you the trouble.

This is not an entry in the “Grunt v. gulp v. whatever else” debate. I’m assuming you’re using Grunt for reasons that make sense to your team, but may be suffering from Grunt tasks that run slowly and are difficult to upgrade, test, maintain, and debug. That’s where I was, and I’m writing this to provide an approach for you to dig yourself out of that hole, based on my experience.

tl;dr:

Don’t think of Grunt as the platform for implementing your logic; think of it as one way your logic can be consumed.

Using Grunt as more than a wrapper hurts more than it helps

In the beginning, my team at Opower was pretty excited about Grunt, so we made the mistake of letting it permeate our tasks at all levels of abstraction. The grunt variable got passed around all over the place. Who needs fs when you have grunt.file? Who needs require(‘lodash’) when you have grunt.util._? Who needs optimist or commander when you have grunt.option? (The list goes on.)

As it turns out, this is bad. Node rightly emphasizes small, focused modules as a protection against dependency hell. Grunt is taking us back to jQuery, where we have a giant monolith that is brittle and frightening to update, because the API surface area is huge. And the monolith itself quickly gets outdated — grunt.util._ was using lodash version 0.9.2 when 2.4.1 was available in the public npm. Why would you tie yourself to that?

Additionally, if your grunt task does something halfway interesting, then odds are someone who isn’t using grunt will want to use that same functionality. If grunt is a dependency throughout your project instead of just being one interface to it, then this is impossible.

It’s even possible that this someone will be you, wanting to use your task from another task. Grunt makes this very painful. Loading one task from another is a little tricky. You can make it work, but it’s not trivial like just requiring another module.

Additionally, there is not a nice way to get control flow back after running a task. When you call grunt.task.run, it does not synchronously run the task, nor does it give you a callback or a promise to know when the task is done. All it does is add that task to the end of the currently running tasks queue.

I’d like to do this:

But instead, I must do:

Now you’re starting to have thoughts like “well, what if someone else creates another task with the same name, and there’s a collision because all tasks share a global namespace? I know! I’ll just put a guid in the task name”. This is not good.

And when you want to call task-b in the above example, how do you pass it config? If the config is statically knowable, then it’s fine to just invoke the task. But if it is something dynamic, and you want to call the task with a set of arguments, then you’re stuck mutating the global config namespace, with all the concerns that entails. We end up with something like:

This sucks. But if we were just using functions, we could do:

Grunt async task handling code omitted for brevity.

Isn’t that nicer?

The lesson I took away from this is that tasks are not a good way of composing functionality. Just use functions.

How to use Grunt only as a wrapper

When it was time to refactor our project, we realized that having Grunt everywhere was no good, for the reasons stated above. Instead, we moved to use Grunt only as a glue layer. To do this, we observed the following guidelines:

Knowledge of Grunt can only exist in the tasks directory.

Nothing in your module outside of the tasks directory can depend on the tasks directory.

This approach gives us the ability to use Grunt when it’s convenient, without having to work around it when it’s not. (One time when it may not be convenient to have to deal with Grunt is testing. Some types of tests are far easier if you can just extract your core functionality instead of having to use the Grunt scaffolding as well.)

Of course, if we are going to be a well-behaved Grunt task, then we want to adhere to the proper standards around config, logging, and error handling, so we play nicely with the rest of the Grunt ecosystem. How can we do that without making our core logic dependent on Grunt?

Config

Just call this.options() and pass it in to your core logic function.

I implore you not to use grunt.option to read command line flags from a context that’s supposed to be re-usable, like a task you are publishing on npm. It’s dangerous because you don’t know what other tasks you will be running alongside, and command line flags they will be reading. It’s a global namespace, and you could have collisions. Instead, I recommend only using command line flags from your Gruntfile, where you do have global knowledge of which tasks will be in use. The Gruntfile is not a re-usable context, so this is safe.

Additionally, we initially made the mistake of storing mutable task state in grunt.config. This is essentially just using global variables, with all the collision danger and poor testability that entails. Don’t do it.

Logging

Take the log function as an argument.

We isolate the grunt-specific logic to the tasks directory, and because we provide a default value of console.log, users who are calling this method from node don’t have to worry about it. (Unless they want to have some control over our logging, of course, in which case this is a nicer approach for us to take than just spamming stdout.)

Another approach is to have your myTask function return an EventEmitter. This allows calling functions or your Grunt glue code to render the events as desired on stdout and stderr.

Errors

Just throw Errors from your task, and put the Grunt-specific error handling logic in the tasks directory.

Passing the error into done() is simplified for brevity.

Your main function should throw errors or fire error events in your EventEmitter or do whatever else you do to signal a problem. If your user is calling you from node, they will handle those errors as they see fit. If your function is being run from Grunt, you’ll have the tasks directory to integrate your errors into Grunt’s error handling systems in whatever way makes sense for you.

What about core Grunt utilities?

How are you supposed to write your core module logic without relying on helpers like grunt.util and grunt.file? It’s easy. Instead of grunt.util, just require your dependencies directly. Instead of grunt.file, use fs or q-io or co-fs or any number of other file handling libraries that may fit your needs.

My team initially thought that grunt.file was supposed to be a nicer API on top of fs, but when we moved to using the latter we saw that the former didn’t provide much value relative to the overhead of another layer of abstraction. The main nice thing we liked was grunt.file.readJSON, but that is a very simple function to re-implement ourselves.

Other Best Practices

In addition to using Grunt only as a wrapper, there are a few other best practices that will make your users’ lives easier.

Be careful with slow, synchronous logic

Each of your task files will get evaluated on every grunt invocation, even if your task isn’t being run. Grunt doesn’t have any notion of lazily loading a task. Putting slow, synchronous logic outside of your task function makes your task a bad citizen. The user will run “grunt” and have a delay before anything happens.

This is the bad way. The most common way people shoot themselves in the foot here is require() statements that are slow.

The way to solve this is to move as much as possible into your task function (myTaskFn in the above example), which will only be evaluated if your task is actually being run:

This is the good way.

This is unlikely to be terribly noticeable for your users unless they are pulling in many tasks, but there’s rarely any downside to moving logic into the task function. require statements are idempotent, so you don’t need to have concerns with calling them multiple times. For other logic, _.once is your friend.

Always make it a multi-task

Unless you make your task a multi-task, you’re saying that you think your user will only ever have one set of config they want to pass to your task. I’ve found that this is rarely the case. For a while, I would default to making my tasks non-multi-tasks, but I had to go back and retrofit so many of them that I just got into the habit of making them multi-tasks to begin with.

Only call done() with real Errors

To indicate to Grunt that the task has failed, you call the done function with some error value. However, if you pass in a value that is not actually an Error instance, Grunt will not consider it to be a failure. Thus, to be safe, you must ensure that the value you are passing truly is an error:

This is a pain, but it’s necessary if you want your task to be robust. If you don’t do this, you’ll have the following result:

The task run exits with exit code 0, which is not what we want.

In Conclusion

Don’t think of Grunt as the platform for implementing your logic; think of it as one way your logic can be consumed. With simple steps outlined above, you can have Grunt as an optional added feature instead of a necessity and potential burden for your users. Your life will be easier when building and maintaining the task, and it will open your module up to a wider audience of people who may not be using Grunt.

Thanks to @dylang for reading drafts of this.