Clean coding in JavaScript for Digital Analysts

Measurecamp Amsterdam 2022 session recap

We all experienced it. We need to revisit some code we wrote 2 years ago… and… we have no clue anymore what the code does. This is something I especially experienced recently when handing over a digital analytics code base. It got clear to me that I should investigate how I could code in a cleaner way. This article is a session recap of my presentation at Measurecamp Amsterdam 2022, with 10 clean coding learnings from me and audience members. But before diving in deep, let's first align on what clean code is.


Working code vs. clean code

With the large number of online resources available today, everyone can write code that works. Clean code moves beyond workable code and focuses on increasing the readability of the code. Or better said, it aims to reduce the cognitive load required to understand the code, much like we aim to reduce the cognitive load of our data visualizations. Applications with clean code also tend to be less buggy, as they often contain less lines of code (per function) and make it clearer what happens. What makes up working code is quite factual as it either works or not. Clean coding practices on the other hand are quite subjective, as not everyone shares the same opinion on what makes up clean code and what not. So, if you are in disagreement with one or more of the learnings below, that's perfectly fine. Just apply those elements you feel are useful. With that disclaimer in mind, let's explore my 10 key learnings.


1. Prioritize readability over performance

Let's start with two philosophic learnings before diving into coding examples. The first one is to prioritize code readability for us humans over code performance. When I started programming, I was happy when my code worked. But when I passed this stage, I started to focus on writing high performing code, aiming to minimize the code execution time. My insights were based on internet articles, and I was proud if I got the code working using the high performant approaches. At some point I was iterating through arrays with a reverse for loop (iterating backward), as according to some internet articles that was the quickest method to iterate through an array with 1.000.000 elements. I soon learned that others found my code hard to read, and I often forgot after a few months how the code worked. Let's be real as Digital Analytics developers: how often do we iterate over an array with more than 50 elements?

To code clean, it is fine to sacrifice some code performance in favor of code readability. Especially as most of our code is (hopefully) loaded asynchronously, this decreased performance should't affect the user experience in any way. However, it makes it much easier to collaborate on code and saves lots of time in case you need to revisit code in the future.

Two tips to test the performance of your code and determine if performance enhancements are required, is to validate the execution times with performance.now() or the console.time() functions. I personally like the console.time() function, where you can start measuring the execution time with console.time(label) and finish the measurement with console.timeEnd(label). With console.time you can use up to 10.000 labels per page. By timing the code performance I observed that the readable code versions are barely slower than the high performant ones, if at all.


2. Write working code first, then clean it up

It is hard to write readable code in one go. So first make it work, then revisit it and rewrite it into readable code. This means you may be splitting up functions, renaming variables and functions, update if-statements and the like. Sometimes your code may require several touch ups, but when you're done it should be easy for most (technical) digital analysts and developers to easily understand what your code does.


3. Prevent else statements

If-else statements are usually one of the first things you learn when programming. However, by using if else { ... } and else { ... } statements in your code, you're introducing conditional logic. This effect can even be enhanced by using nested if { ... } statements. This increases the congnitive load of the code, as readers need to remember the conditions that need to be met for the code to run. It also makes unit testing more complex. So how to work around this? Let's look at a simple before and after if else statement

            
/* General if else condition */
  if (true) {
    /* do something */
  } else {
    /* run fallback */
  }
  return result

/* Can be rewritten to: */
  if (!true) {
    /* run fallback */
    return result
  }
  /* do something */
  return result
            
          

Now we have rewritten the code without an else statement and conditional logic. This is more easy to read, exits early in the false condition, and the normal flow is kept at indention-zero.

Let's look at one more example and how it can be rewritten. Assume you have a function that behaves different dependent on the daypart. We will have a look on how this can be rewritten with only if statements, and we will clean it up even more by using no if statements at all.

            
/* Original function, as you initially might write it */
  function getDaypart (hourOfDay) {
    let daypart;
    if (typeof hourOfDay !== 'number' || hourOfDay > 23) {
      return;
    } else if (hourOfDay < 6) {
      daypart = 'night';
    } else if (hourOfDay < 12) {
      daypart = 'morning';
    } else if (hourOfDay < 18) {
      daypart = 'afternoon';
    } else if (hourOfDay < 24) {
      daypart = 'evening';
    }
    return daypart;
  };

/* Rewritten without else { ... } statements */
  function getDaypart (hourOfDay) {
    if (typeof hourOfDay !== 'number' || hourOfDay > 23) {
      return;
    } 
    if (hourOfDay < 6) {
      return 'night';
    } 
    if (hourOfDay < 12) {
      return 'morning';
    } 
    if (hourOfDay < 18) {
      return 'afternoon';
    } 
    if (hourOfDay < 24) {
      return 'evening';
    }
  };

/* 
 * It goes beyond this learning, but there is another option to 
 * remove the if statements completely
 * this will return an undefined value by default if not matched 
 * with any keys
 */
const mapHourToDaypart = {
  0: 'night,'
  1: 'night,'
  2: 'night,'
    /* ... continue the same mapping logic for 3 to 21 ... */
  22: 'evening',
  23: 'evening'
  };

function getDaypart (hourOfDay) {
  return mapHourToDaypart[hourOfDay];
};
           
          

Depending on your situation you can likely clean up your code by using one of the two improvements. The first option is often better when you're dealing with decimal numbers, larger ranges, arrays and objects. The second option is often better when you're dealing with text values or smaller ranges of integer values.


4. Use optional chaining

In digital analytics we work a lot with objects which are different from page to page. Object keys that exist on some pages or in some situations do not necessarily always exist. Instead of long if statements to validate if a key exists on an object, we can also use optional chaining. We do this by adding a question mark before accessing a property. An optional chain will return undefined if the key does not exist, and otherwise return the property value.

            
/* this is the old way of doing it*/
const userId = digitalData.user.userInfo.userId;

/* 
 * The above assignment can throw a TypeError, like:
 * TypeError: Cannot read properties of undefined (reading 'userInfo') 
 * This can be prevented by adding property existence validations
 */
const userId =
  typeof digitalData !== "undefined" &&
  digitalData.user &&
  digitalData.user.userInfo
    ? digitalData.user.userInfo.userId
    : "(not set)";


/* The above code will work with the '(not set)' string as fallback,
 * but is not concise. 
 * this can be solved with optional chaining,
 * which does exactly the same
 */
const userId =
  typeof digitalData !== "undefined"
    ? digitalData?.user?.userInfo?.userId
    : "(not set)";

/*
 * Optional chaining works for all properties, also function calls 
 */
const userId =
  typeof digitalData !== "undefined"
    ? digitalData?.user?.userInfo?.userId?.trim()?.toLowerCase()
    : "(not set)";            
              
              

So Optional chaining allows us to access deep data structures without checking if each level exists. Optional chaining can be used with any property, so it can also be used to call functions.

One important thing to keep in mind is that we always need to check or ensure that the main object exists, to prevent any reference errors due to a non existing object (digitalData in my example). This can be done by adding extra validation to the value assignment, or by wrapping the code in a try catch statement.

So optional chaining can make our code more concise, but we still need to add appropriate validations or error handling to prevent any errors. My code example uses a typeof validation, but typeof validations can easily introduce bugs in to our code. So, let's look at how we can use a cleaner method to perform the data checks.


5. Perform strong data type checks

It is easy to introduce bugs into your code by performing weak and/or improper data checks. Especially when you create a generic digital analytics code base than runs on multiple websites and/or apps, it is very likely that bugs are introduced when new data points are added with incorrect formats.

A common way to test the data type of a variable is performing a typeof validation. Where this works fine for primitive values (variables that hold a single value like a number or string), this offers challenges for reference values (like arrays, object literals, maps and sets). These reference values will all return the value 'object' when checking their type this way. So, this always requires additional checks to validate if you are dealing with an array or object literal. Besides this, typeof null also returns 'object', which can cause unexpected exceptions. So how do you solve this? Well, by getting the actual data type from the object prototype using this formula:

          
/**
 * @param {*} variable can be called with any variable
 * @returns {string} the data type of the variable in lower case
 */
const getDataType = (variable) => {
  const dataType = Object.prototype.toString.call(variable);
  return dataType?.split(' ')[1]?.replace(']', '')?.toLowerCase();
};
          
          

This formula returns the actual data type as a string in lower case. It works for null, undefined, strings, numbers, arrays, object literals, and all other data types. Your code gets so much better readable and reliable if you use this formula. You just perform simple checks for which it is readable what you are checking against. Just have a look at the following two examples:

          
/* check for array */
getDataType([1,2,3]) === 'array'  

/* check for object literal */
getDataType({key: 'value'}) === 'object'
            
          

When we are expecting numbers, we can expand these validations by adding parseInt or parseFloat transformations. This enhances the code robustness for situations where numbers are set with a string value. Let's assume a scenario where a variable is set with value '0' where a number like 0 is expected. If you run the validation in the example below, it will work with both numbers and stringified numbers.

          
getDataType(parseFloat('0')) === 'number'  /* validates to true */

getDataType(parseFloat(0)) === 'number'  /* validates to true */
            
          

6. Each function should only have one function

The title of this learning seems so obvious, but in digital analytics code the principle is not always followed. This is especially true for code in that lives in custom code tags in tag managers, or custom templates in (server-side) Google Tag Manager. There is one main body with code, in which many different things are handled. Often one needs to read (read: reverse engineer) the full code to get an understanding of it is doing.

Even though this is the way that many people write code, it is good practice to revisit your code once you got it working. Can you split up the main body code into multiple functions, that each do one thing and each have a clear separation of concerns.

It is also good practice to use 'reusable' functions that you can apply everywhere in your code. This prevents us from writing the exact same code every time we need it. Even though this is (hopefully) common practice for bundled code we produce in a code repository, this is still underutilized in custom code tags we use in tag managers.

So, once you get your code working, focus on on splitting up your code in multiple functions that have each one concern. This also applies to certain validations and guard clauses in the code. These can sometimes contain so many elements, that they actually deserve a function on their own.

While we are on the topic, also make sure that your functions only return values of one data type (besides undefined). Good code readability also means being consistent in the data type that different (if) branches in the code return.


7. Use clear variable and function names

I'm definitively guilty for using unclear variable and function names in my code for a long time. As long as it worked, I was happy. And while delving into server-side Google Tag Manager the past few months, I got to understand that I am certainly not the only one doing this. By using unclear names you introduce a lot of cognitive load in your code. As there are many ways to minify and bundle you code these days, there is absolutely no reason anymore to do this. By naming functions based on what they do and naming variables based on what they hold, the code gets way more readable. Here are some examples:

            
/* suboptimal: use unclear variable names */
const e = { cat: "cat", name: "name" };

/* better: name the variable by what it contains */
const analyticsEvent = { category: "category", name: "name" };

/* suboptimal: function and argument names for a function that handles video events */
const video = (c, n, d) => {
  /* implementation */
};

/* better: name the function by what it does and the function arguments by what data they hold */
const processVideoEvent = (eventCategory, eventName, eventData) => {
  /* implementation */
};

/* suboptimal: one letter function arguments in higher order array functions, like forEach */
[1, 2, 3].forEach((e, i) => {
  /* implementation */
});

/* better: name arguments by what data they hold in higher order array functions, like forEach */
[1, 2, 3].forEach((number, index) => {
  /**
   * are you handling a number, then name the first argument number
   * is it an event, then name the first argument event
   * is it an element, then name the first argument element
   * etc...
   */
  /* implementation */
});

/* suboptional: use lookup as variable name for a lookup object */
const lookup = {
  click: processClickEvent,
  video: processVideoEvent,
  ecommerce: processEcommerceEvent,
};

/* better: name the variable of a lookup object by what it maps */
const mapEventNameToMethod = {
  click: processClickEvent,
  video: processVideoEvent,
  ecommerce: processEcommerceEvent,
};
            
          

8. Use default function parameters

One thing I was underutilizing, but find extremely helpful, is using default values for function parameters. Function parameters are by default initialized with the value undefined. It can be extremely useful in some occasions to set a default value to create more concise code. In this way you don't have to test in the function body if a value exists. Both of the following functions do the same thing, but the second uses default parameters and is as a consequence more concise and better readable.

          
/* function without default parameter values */
const handleEventWithoutDefaultValues = (
    eventName,
    eventCategory,
    eventData,
) => {
  eventName = eventName || "defaultName";
  eventCategory = eventCategory || "defaultCategory";
  eventData = eventData || {};
  /* implementation */
};

/* function with default parameter values */
const handleEventWithDefaultValues = (
  eventName = "defaultName",
  eventCategory = "defaultCategory",
  eventData = {},
) => {
  /* implementation */
};
          
          

Note that the first function may even contain bugs in case the arguments should handle empty strings or the number 0, as in the first function the variables will default to the fallback value for 0, "" (empty string), null, and undefined.

There are a lot of things you can do with default parameters. You can find a great overview in this MDN article.


9. Perform automated code unit tests with code coverage reports

For a long time, I used to rely on manual testing pieces of code. Recently I took the time to learn proper unit testing, in Jasmine in my case. Unit testing allows us to test code at a function / module level, ensuring that pieces of code work in isolation. It was so much easier to pick-up than I anticipated, although the initial configuration in the repository took some time. Once I started doing it, it contributed so much to the quality of the code base.

Here are some of my key learnings:

  • You easily identify functions that fail (edge) test cases, so it helps to enhance code quality.
  • Through coverage reports, which indicate the share of code covered by the tests, I discovered pieces of code that simply couldn't be triggered, for example due to guard clauses. So, unit testing helps in removing unused pieces of code.
  • When refactoring our enhancing pieces of code, you can rely for a large amount on your unit test to ensure that everything still works.

So by introducing unit tests the technical dept of the code can be reduced, code quality can be improved, and it gets easier to validate if code improvements don't break existing functionalities.


10. Minimize code comments

From all 10 learnings, this is probably the most debatable. I added this learning because I believe that when you feel the need to comment your code, it is probably still too complex. Adding comments can make your code look dirtier and more cluttered. So instead of adding comments, look for ways to enhance the code by other learnings in this article. Especially if you have written unit tests for your code, the unit tests by itself should act as documentation on how the code works.

However, there may still a few things you may want to comment:

  1. Help articles or solutions you found online and are not straight forward, like for example a Stack Overflow answer, an online tutorial, or the like. This may not only come in handy in the future, when you have a comparable challenge, but it is also useful for code reviewers.
  2. When calling APIs or writing promises, it is often useful to document the format of the returned values. This makes the code better understandable, and is useful when the code needs updating after an API / promise change.
  3. Tricky parts of the code that can't be further simplified, or where further simplication sacrifices performance too much.
  4. To Do's, like functionalities still to build or enhancements still to make.

However, comment wisely and be aware that a lot of things can be found online, like how certain native JavaScript methods work.

Please follow the JSDoc guidelines when adding JavaScript comments. This helps to comment things in a structural, concise and consistent manner.

And there you have my learnings. Clean coding is about minimizing the cognitive load of your code, so it get's easier to read for you and others. You can achieve this by revisiting your code once you got it working, find ways to minimize the amount of code (in functions and modules), name things what they hold and do, and reduce conditional/nested logic.

In case you have questions or comments with regards to this article, feel free to reach out on LinkedIn

  • Was this article helpful?