Code Review Videos > JavaScript / TypeScript / NodeJS > Beginner Friendly Nunjucks Custom Filters

Beginner Friendly Nunjucks Custom Filters

If you work on any JavaScript / Node / TypeScript GOV.UK project, sooner or later you will come across the Nunjucks template language. And depending on your prior experience, you may find the whole thing quite limiting or confusing.

Personally I think it’s good at what it does. It’s not fancy like React or Vue, but it is functional and gets the job done. Perhaps I am helped by having used a very similar template library in my past life as a PHP developer – being that Nunjucks is seemingly very inspired by Python’s Jinja2, as are Twig in PHP, Swig in Node, and likely several other similar approaches to the same problem.

As a side note: it can be useful to look at the Jinja2 / Twig docs for more information / explanations on concepts such as filters, templates, macros, and all that. Sometimes Nunjuck’s docs don’t contain all the details so knowing about the others can add value. However, they aren’t always 1:1 in functionality, so expect syntax differences.

OK, onwards to the problem at hand.

What Are Nunjucks Filters

Nunjucks filters are functions that allow you to modify or transform values within Nunjucks templates. Filters are applied to variables using the pipe (|) symbol, and they can manipulate the data in various ways.

Nunjucks provides a set of built-in filters that cover common operations such as string manipulation, formatting, and filtering data collections. Some examples of built-in filters include:

  • capitalize: Converts the first character of a string to uppercase (note the USA 🇺🇸 spelling).
  • lower: Converts a string to lowercase.
  • upper: Converts a string to uppercase.
  • truncate: Truncates a string to a specified length.
  • join: Joins elements of an array into a string with a specified delimiter.

Here’s an example of using filters in Nunjucks:

<!-- Template using Nunjucks -->
{% set myString = "hello world" %}

{{ myString | capitalize }} <!-- outputs: Hello world -->
{{ myString | upper }} <!-- outputs: HELLO WORLD -->
{{ myString | truncate(4) }} <!-- outputs: hell... -->Code language: Twig (twig)

In this example, we define a variable myString with the value “hello world”. Then, we apply different filters to the myString variable using the pipe symbol (|). The capitalize filter capitalizes the first letter, the upper filter converts the string to uppercase, and the truncate filter limits the string to a length of 5 characters.

You can look at the source code to see how these filters work. For example, here is the source on GitHub for truncate.

Chaining Nunjucks Filters

Using an individual Nunjucks filter is less common, in my experience, than using several at once.

Here’s an example of combing several built in Nunjucks filters into a chain:

<!-- Template using Nunjucks -->
{% set names = ["Jane", "John", "Alice", "Bob"] %}

{{ names | sort | reverse | join(", ") }}Code language: Twig (twig)

We apply three filters consecutively using the pipe symbol (|).

First, we apply the sort filter to sort the names in alphabetical order.

Then, we apply the reverse filter to reverse the order of the sorted names.

Finally, we apply the join filter with a delimiter of ", " to join the names into a comma-separated string.

The output of this template would be "John, Jane, Bob, Alice", where the names are sorted in reverse alphabetical order and joined with commas.

Sometimes, however, the built in Nunjucks filters don’t quite do what you need. In which case you can tackle the problem in several ways. One of which is to create a custom Nunjucks filter.

When To Use A Custom Nunjucks Filter

You can also define your own custom filters in Nunjucks to extend the functionality according to your needs. Custom filters allow you to create reusable transformations or manipulations specific to your project’s requirements.

Let’s take a fairly common example of outputting a list of names. We did this above, and got a functional, if rather rustic outcome.

Our Fake Stakeholder Demands

Our imaginary stakeholder has asked that we format the list of names in a nicer fashion. Here are their requirements:

  • When one name, output that name.
  • When two names, output “personA, and personB”.
  • When three names, output “personA, personB, and personC”.
  • For greater than three names, output as a comma separated list, where the final name is “, and lastPerson”.

I’ll leave it up to you to debate the benefits of the Oxford comma.

The Inline Monstrosity Solution

We could solve this directly in a nunjucks template using an if / else / for abomination:

{% set names = ["John", 'David', "Jane", "Alice", "Bob", "Charlie", "Eve"] %}

{% set sortedNames = names | sort %}
{% set namesCount = sortedNames | length %}

{% if namesCount === 1 %}
  {{ sortedNames[0] }}
{% elseif namesCount === 2 %}
{{ sortedNames[0] }}, and {{ sortedNames[1] }}
  {% elseif namesCount === 3 %}
{{ sortedNames[0] }}, {{ sortedNames[1] }}, and {{ sortedNames[2] }}
{% else %}
  {% set lastPersonIndex = namesCount - 1 %}
  {% for i in range(namesCount) %}
    {% if i < lastPersonIndex %}
      {{ sortedNames[i] }},
    {% elseif i === lastPersonIndex %}
      and {{ sortedNames[i] }}
    {% endif %}
  {% endfor %}
{% endif %}Code language: Twig (twig)

And I know what you might be thinking: But Chris! There is a slice filter, why not just use that?

Try it. I had intended to do so for this example, thinking it would easily solve this problem. Turns out it doesn’t behave how you’d think, and the example on the docs is misleading (or simply, wrong).

I could have changed this example, but I feel this is actually a really good real world experience with Nunjucks. You think something will be fairly straightforward, and then it turns out to be harder than you’d imagine.

Oh, and we haven’t even mentioned the T-word, have we?

Yeah, good luck testing that.

The Custom Filter Approach

A far nicer way, in my opinion, is to extract all this crap logic to a plain old JavaScript function.

We can then unit test this function in isolation, and add it into our Nunjucks global configuration as a filter. The nice part about that, aside from actually being able to easily test your code, is that you can re-use this filter anywhere your Stakeholder demands that specific output.

Here’s the plain JS to solve this problem:

function formatNames(names) {
  const sortedNames = names.slice().sort();
  const namesCount = sortedNames.length;

  switch (namesCount) {
    case 1:
      return sortedNames[0];
    case 2:
      return sortedNames.join(' and ');
    case 3:
      return `${sortedNames.slice(0, 2).join(', ')}, and ${sortedNames[2]}`;
    default:
      return `${sortedNames.slice(0, -1).join(', ')}, and ${sortedNames[namesCount - 1]}`;
  }
}Code language: JavaScript (javascript)

I’d argue that not only is that much simpler to read and understand, but that it immediately highlights one glaring bug:

I forgot to handle the zero / empty case.

Because of the noise in the template view, it is so much easier to miss that. I guess it might have been picked up in code review, maybe?

Here’s a refactored version of the function:

function formatNames(names) {
  if (!Array.isArray(names)) {
    throw new Error('Names must be an array.');
  }

  if (names.some(name => typeof name !== 'string')) {
    throw new Error('All names must be strings.');
  }

  const sortedNames = names.slice().sort();
  const namesCount = sortedNames.length;

  switch (namesCount) {
    case 0:
      return '';
    case 1:
      return sortedNames[0];
    case 2:
      return sortedNames.join(' and ');
    case 3:
      return `${sortedNames.slice(0, 2).join(', ')}, and ${sortedNames[2]}`;
    default:
      return `${sortedNames.slice(0, -1).join(', ')}, and ${sortedNames[namesCount - 1]}`;
  }
}

module.exports = formatNames;Code language: JavaScript (javascript)

And the associated tests:

describe('formatNames', () => {
  test('should return an empty string for empty names array', () => {
    const names = [];
    const result = formatNames(names);
    expect(result).toBe('');
  });

  test('should throw an error if names is not an array', () => {
    const names = 'Alice';
    expect(() => {
      formatNames(names);
    }).toThrow('Names must be an array.');
  });

  test('should throw an error if names contain non-string elements', () => {
    const names = ['Alice', 'Bob', 123];
    expect(() => {
      formatNames(names);
    }).toThrow('All names must be strings.');
  });

  test('should return a single name', () => {
    const names = ['Alice'];
    const result = formatNames(names);
    expect(result).toBe('Alice');
  });

  test('should join two names with "and"', () => {
    const names = ['Alice', 'Bob'];
    const result = formatNames(names);
    expect(result).toBe('Alice and Bob');
  });

  test('should format three names with commas and "and"', () => {
    const names = ['Alice', 'Bob', 'Charlie'];
    const result = formatNames(names);
    expect(result).toBe('Alice, Bob, and Charlie');
  });

  test('should format more than three names with commas and "and"', () => {
    const names = ['Alice', 'Bob', 'Charlie', 'David', 'Eve'];
    const result = formatNames(names);
    expect(result).toBe('Alice, Bob, Charlie, David, and Eve');
  });

  test('should sort the names before formatting', () => {
    const names = ['David', 'Bob', 'Eve', 'Alice', 'Charlie'];
    const result = formatNames(names);
    expect(result).toBe('Alice, Bob, Charlie, David, and Eve');
  });
});Code language: JavaScript (javascript)

Sweet.

Now, let’s hook that into Nunjucks:

const nunjucks = require('nunjucks');
const formatNames = require('./utils/formatNames');

const env = nunjucks.configure();

env.addFilter('formatNames', formatNames);

// Additional configuration and usage of NunjucksCode language: JavaScript (javascript)

Of course, that is just an example config – how yours is set up will very likely differ. Such are the joys of every NodeJS project ever, they are all slightly different.

The main thing is that this formatNames function is declared in a standalone file and require‘d into the nunjucks setup file. The alternative, and what I see all too frequently, is the filter functions defined inline, such as:

env.addFilter('formatNames', () => {
  // do things here
});Code language: JavaScript (javascript)

Which again makes things much harder to test… usually meaning that those filters are not tested. I’ll never understand the whole attitude of not testing stuff, but that’s a different issue.

Using The Custom Filter

We’ve done a lot of work to extract, setup and test our custom filter.

How do we now use it?

Well, the very same way we use the built in filters:

{% set names = ['Alice', 'Bob', 'Charlie'] %}

Formatted Names: {{ names | formatNames }}Code language: Twig (twig)

And yes, you could still combine your custom filters and built in filters into a chain also. It doesn’t make much sense here, but it is possible.

Why Not A Custom Macro?

Why are there always multiple ways to solve a problem?!

Arghh.

Well, yes, we could also solve this problem using a macro. It doesn’t take much to convert that messy rats nest of if / else / for into a custom Macro:

{% macro formatNames(names) %}
  {% set sortedNames = names | sort %}
  {% set namesCount = sortedNames | length %}

  {% if namesCount === 1 %}
    {{ sortedNames[0] }}
  {% elseif namesCount === 2 %}
    {{ sortedNames[0] }}, and {{ sortedNames[1] }}
  {% elseif namesCount === 3 %}
    {{ sortedNames[0] }}, {{ sortedNames[1] }}, and {{ sortedNames[2] }}
  {% else %}
    {% set lastPersonIndex = namesCount - 1 %}
    {% for i in range(namesCount) %}
      {% if i < lastPersonIndex %}
        {{ sortedNames[i] }},
      {% elseif i === lastPersonIndex %}
        and {{ sortedNames[i] }}
      {% endif %}
    {% endfor %}
  {% endif %}
{% endmacro %}

{% set names1 = ['Alice', 'Bob', 'Charlie'] %}
{% set names2 = ['David', 'Bob', 'Eve'] %}

Formatted Names1: {{ formatNames(names1) }}
Formatted Names2: {{ formatNames(names2) }}Code language: Twig (twig)

So when to use a custom macro vs a custom filter?

Personally I look to the GOV.UK components for this:

nunjucks custom filters vs macros in gov uk components

Notice the line:

{% from "govuk/components/radios/macro.njk" import govukRadios %}Code language: Twig (twig)

Using a macro is preferable here because they allows us to create complex HTML components with dynamic content.

Macros give a way to encapsulate reusable HTML components and include logic within them, allowing for greater flexibility and customisation.

However, most of my time is spent adapting values for display rather than creating brand new components. I can’t actually remember ever creating a macro on a GOV.UK project. I did used to do that more frequently back when I heavily used Twig…

Besides which, the Macro approach still suffers from the problem of being far harder to test. You can still unit test them, and for that I would suggest you browse the Nunjucks source code for the GOV.UK front end components over on GitHub, and see how they test their macros.

Wrapping Up

In conclusion, Nunjucks filters provide a powerful way to modify and transform values within Nunjucks templates. While the built-in filters cover common operations, there may be cases where you need custom functionality. Creating a custom Nunjucks filter allows you to extend the capabilities of Nunjucks to meet your project’s specific requirements.

By extracting complex logic into a plain JavaScript function and unit testing it in isolation, you can ensure the accuracy and reliability of your custom filter. Integrating the filter into your Nunjucks configuration enables its usage throughout your templates, providing a reusable solution for formatting or manipulating data.

Alternatively, you can also solve similar problems using custom macros, especially when creating complex HTML components with dynamic content. Macros allow for greater flexibility and customisation by encapsulating reusable HTML components and incorporating logic within them.

While both custom filters and macros have their use cases, filters are often more suitable for adapting values for display purposes. In contrast, macros shine when creating reusable components with complex functionality.

Regardless of the approach you choose, separating your filter logic from the template itself and properly testing it can greatly improve code maintainability and reliability. By leveraging the capabilities of Nunjucks filters, you can enhance the power and flexibility of your JavaScript, Node, or TypeScript Nunjucks projects.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.