Talking technique: Recognizing context for cleaner design

kirkcodes

Kirk Shillingford

Posted on November 17, 2021

Talking technique: Recognizing context for cleaner design

A Short Introduction

This is a short post covering a relatively useful pattern for writing functions that I have found very applicable to anyone writing modern software. The pattern itself isn't particularly arcane, and many developers find themselves adopting this style with time.

However, I've found that sometimes, speaking about something explicitly can accelerate learning and understanding faster than trying to intuit things over time. I remember being fairly excited once I noticed the pattern and grateful that once I brought it up, someone more senior than myself took the time to break it down.

So let's see if I can pass it on.

So what's the pattern

Sometimes, I feel like the best way to approach things is to lay an elaborate groundwork of pieces and slowly assemble the puzzle together with the reader. But this time, I think it's best to start with the final statement, so let's just start with defining the pattern itself.

"User-defined functions should try not to consume "container" data structures.

Those data structures should be manipulated at a higher level by built-in features of the language itself."

If the above statement doesn't immediately click, that's okay! That's what this article is for. Since we'll be looking at examples in Javascript, I also have a more specific version of the statement for js development, which goes:

"User-defined functions should try not to consume Arrays, Promises, and Nullables. Those should be manipulated by the built-in methods of their respective libraries.

User-defined functions should try to concern themselves with the values inside the container data structures instead."

Still unclear? That's fine. Let's examine this more in-depth with some examples.

Example one: Manipulating the elements in an array.

Let's take a look at the following code

const radii = [1, 4, 7, 10, 13]

const sphericalVolumes = (radii) => {
  const volumes = []
  radii.forEach(radius => {
    const volume = (4 / 3) * Math.PI * radius ** 3
    volumes.push(volume)
  })
  return volumes
}

console.log(sphericalVolumes(radii))

// [4.1887902047863905, 268.082573106329, 1436.7550402417319, 4188.790204786391, 9202.7720799157]
Enter fullscreen mode Exit fullscreen mode

We've created this function, sphericalVolume(), that accepts a list of "radii" (radiuses? I don't honestly know) and calculates the Volume of the corresponding sphere. This function is fine, but there are a few things we could critique here:

  • By having the function consume an array, and by using forEach(), we've bound it to always consuming an array-like structure. If we ever decide to use a different container for our radiuses (like a list or a set), this will break.
  • Consuming a list also makes our tests more complicated. In addition to checking the actual calculation of the spheres, we now have to also ensure this maintains the right behaviour when the list is empty or contains non-numerical values. Neither of which has anything to do with the function's true purpose; calculating a volume from a radius.
  • Another added complexity of the tests is that the value returned is now an array that must be unpacked to retrieve the value.

Let's compare it to this refactored version:

const radii = [1, 4, 7, 10, 13]

const sphericalVolume = (radius) => (4 / 3) * Math.PI * radius ** 3

console.log(radii.map(sphericalVolume))

// [4.1887902047863905, 268.082573106329, 1436.7550402417319, 4188.790204786391, 9202.7720799157]
Enter fullscreen mode Exit fullscreen mode

Here, we leverage the fact that arrays already have the tools to transform values in that container. We can do away with most of the trimming around that original function with the built-in map() method, and most importantly, our custom function accepts and returns a single value now.

Testing this function is way easier than before because it always gets a number and returns a number. In Javascript, we can't guarantee it will be passed in a number (in Typescript, we can), but if it does get passed in something else, that's not its job to guarantee.

Although this article isn't explicitly about overly defensive coding, this pattern does help you avoid it. As a rule of thumb,

functions should not both validate an incoming input and perform an operation.

It is the caller's job to ensure the values it passes to the function are correct.

Let's see that more clearly in another example.

Example Two: Manipulating a value that may be null or undefined

const samplePerson = {
  id: 25,
  title: "Dr",
  firstName: "Justin",
  lastName: "Belieber"
}

const people = [samplePerson]

const makeGreeting = (person) => {
  if (person) {
    return `Hello ${person.title} ${person.firstName} ${person.lastName},`
  } else {
    return "Hello Valued Customer,"
  }
}

const person1 = people.find(person => person.id === 25)
const person2 = people.find(person => person.id === 77)

console.log(makeGreeting(person1))
console.log(makeGreeting(person2))

// "Hello Dr Justin Belieber,"
// "Hello Valued Customer," 
Enter fullscreen mode Exit fullscreen mode

So here we have a mock of doing some kind of data retrieval from an array. This array is supposed to contain objects with information about people's names and titles, identifiable by a unique id. We use the find() method to get the objects, but find() will return undefined if it fails to find a matching element of the array. Our makeGreeting() function receives this value, checks if it isn't undefined, and returns either a custom, or generic message accordingly.

You can probably already see the problem here, but let's look at a potential alternative.

const samplePerson = {
  id: 25,
  title: "Dr",
  firstName: "Justin",
  lastName: "Belieber"
}

const people = [samplePerson]

const makeGreeting = (person) =>
  `Hello ${person.title} ${person.firstName} ${person.lastName},`

const possible = people.find(person => person.id === 25)
const greeting = possible ? makeGreeting(possible) : "Hello Valued Customer,"

console.log(greeting)

// "Hello Dr Justin Belieber,"
Enter fullscreen mode Exit fullscreen mode

Here again, we've done what we did in the first example. We've moved the validation out of the function and now ensured that it only ever has to deal with real concrete values.

Thanks to things like the ternary and the nullish coalescing operator, we can handle logic concerning whether a value exists using native language features without concerning the custom function.

This gives us similar testing, readability, and refactoring advantages as we did before.

Now you might have noticed, earlier in the article, I referred to these data structures as "container" structures. But container may not be the best term to describe something like a value that may be null. Another way we can describe these are values in context:

  • the values in the first example have the context of being held inside an array
  • the values in the second example have the context of maybe not existing

Phrased like that, it might seem a little more obvious why it's so much easier to write and manipulate functions that work with values that exist and are usable, rather than ones that we aren't sure about.

To wrap up, let's look at just one more example.

Example Three: Handling Promises

This last example will be the most lengthy, but I promise it's just a continuation of the same patterns we've seen so far. It just requires a bit more preamble to make sense.

const processResponse = (response) => {
  if (response.ok) {
    const { name, sprites, types } = response.json();
    const sprite = sprites.front_default;
    const types_ = types.map((o) => o.type.name);
    return { name: name, sprite: sprite, types: types_ };
  } else return null;
};

const addChildren = (parent, ...children) => {
  for (let child of children) {
    parent.appendChild(child);
  }
};

const getData1 = async () => {
  const pokeDiv = document.getElementById("pokedex");
  const id = Math.floor(Math.random() * 899);
  const address = `https://pokeapi.co/api/v2/pokemon/${id}`;

  const response = await fetch(address);

  const data = processResponse(response);

  if (data) {
    const { name, sprite, types_ } = data;
    const nameDiv = document.createTextNode(name);
    const spriteDiv = document.createElement("img");
    const typeDivs = types_.map((type) => document.createTextNode(type));
    spriteDiv.src = sprite;
    addChildren(pokeDiv, nameDiv, spriteDiv, ...typeDivs);
  }
};
Enter fullscreen mode Exit fullscreen mode

So what's going on here?

This is a snippet of part of the logic for my Pokedex New Tab Chrome Extension project (really rolls off the tongue right).

  • We use fetch to request some data from the pokemon api.
  • We make a function, processResponse() that accepts the results of that fetch, checks whether it was successful, and then extracts the relevant data, and then returns that transformed data, or null
  • Back in the calling function, we update our html with the relevant poke-info if data returned has a meaningful value.

Once again, with processResponse() we've got a function that's attempting to both make sense of some context, and manipulate the objects inside it.

Also, because it sometimes returns null, we have to validate again in the main function on the data returned. Does null even make sense as a return value here? Should it perhaps be an error? This whole thing feels a little too unwieldy for a simple data fetch.

Can we leverage existing tools in the language to handle some of this?

const processResponse2 = (payload) => {
  const { name, sprites, types } = payload.json();
  const sprite = sprites.front_default;
  const types_ = types.map((o) => o.type.name);
  return { name: name, sprite: sprite, types: types_ };
};

const getData2 = async () => {
  const pokeDiv = document.getElementById("pokedex");
  const id = Math.floor(Math.random() * 899);
  const address = `https://pokeapi.co/api/v2/pokemon/${id}`;

  await fetch(address)
    .then((response) => {
      const { name, sprite, types_ } = processResponse(response);
      const nameDiv = document.createTextNode(name);
      const spriteDiv = document.createElement("img");
      const typeDivs = types_.map((type) => document.createTextNode(type));
      spriteDiv.src = sprite;
      addChildren(pokeDiv, nameDiv, spriteDiv, ...typeDivs);
    })
    .catch((error) => {
      throw Error(error);
    });
};
Enter fullscreen mode Exit fullscreen mode

So what's going on in this version of our logic? Well now, we're leveraging the then() method on our promise object to pass the value that we want, the object from the successful response.

processResponse() therefore no longer has to concern itself with whether the response succeeded; it's a function that is only there for when a success happens. The ambiguity of our logic goes away, and we even get to use the catch() method to handle errors any way we choose.

Cleaner code that easier to reason about, extend, and manipulate.

Final thoughts

I hope this little foray into code design was useful to you. This is a broad and deep space, and I wish I had more time to present a more substantial mapping of the principles behind these tactics, and how to build upon them. Hopefully this article and others like it can spark interest and thought in the craft of good code, and what the goals are when refactoring.

"Values in context" are the type of thing where once you notice them, you start seeing them everywhere, because they are everywhere. Knowing when we need to manipulate an array vs just transforming the values inside seems small, but it's the type of thing that can make the difference between spaghetti logic and functions that are easy to reason about.

As always, please reach out if you have any questions, comments, or feedback.

I hope this was valuable for you. Thank you for your time.

Additional Notes

  • If you want to approach this from a more academic standpoint, the entire class of "contexts that contain a value" that we've looked at here are referred to as Functors. There's a very precise definition of what functors are and how they work but many people just remember them as contexts that are mappable. map(), then(), and the ternary operator all do the same thing; they allow us to safely work with a value in some context without disturbing the context itself.
  • A note on dogma: Like everything in software these techniques are suggestions and not absolutes. There are very legitimate reasons for functions to consume arrays and nullables and promises; this was just a way of highlighting that that shouldn't always be the default. For example, a sum function that is actually performing a transformation on an entire array, would need that entire area.
  • In the first example, you might be tempted to think that the second solution seems better partially because we replaced a more verbose forEach() with the minimal syntax of map(), but the solution of map() in the array consuming version has its own even more subtle flaw.
const sphericalVolumes = (radii) =>
  radii.map(radius => (4 / 3) * Math.PI * radius ** 3)
Enter fullscreen mode Exit fullscreen mode

This code, while having the same issues as its more verbose version, suffers from another potential anti-pattern:

sphericalVolumes() in this case is just a thin abstraction over radii.map(radius => (4 / 3) * Math.PI * radius ** 3). So thin, in fact, that you could argue that unless we use this function in multiple places, the abstraction isn't worth hiding the code behind an interface. In other words, wrapping radii.map(radius => (4 / 3) * Math.PI * radius ** 3) in sphericalVolumes() just hides code away that would've been easy enough to understand anyway. The abstraction doesn't help us make sense of the code; it just makes it harder to discover.

💖 💪 🙅 🚩
kirkcodes
Kirk Shillingford

Posted on November 17, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related