Function Encapsulation: Your Code's Secret Weapon

Function Encapsulation: Your Code's Secret Weapon

This post will be short and sweet, yet it is my sincere hope that after reading this blog post, you will think about functions a little differently than you did before.

Functions are one of the first abstractions a programmer learns. Second only to variables. We learn functions as a means of repeating a set of instructions, and later how to parameterize that set of instructions with function arguments. Perhaps you were even taught something like this:

function sayHello(name) {
  return 'Hello, ' + name
}

sayHello('Kyle') // Hello, Kyle
sayHello('friend') // Hello, friend

Now, you have a function you can reuse. Reusability was and is touted as the great benefit of functions. The primacy of this benefit is touted everywhere with lesson upon lesson on DRY code. 1 However, I will contend that reusability is not the primary purpose of a function. No, reusability is rather a wonderful property of functions that comes as the result of their true primary purpose: encapsulation.

What is “encapsulation”?

"Encapsulation" is the act of taking all the elements of a concern and containing them within a structure. The most common structure of encapsulation is a function, as I will discuss here, but you can also think of modules, objects and classes (and more) as structures for encapsulation (if used well).

But what do I mean by all the elements of a concern? Let's come up with an example of some code suffering from a lack of encapsulation.

function createBudgetSummary(transactions) {
  const { length } = transactions
  let income = 0
  let spent = 0

  transactions.forEach(transaction => {
    const { inflow, outflow } = transaction
    if (inflow) {
      income += inflow.amount
    }
    if (outflow) {
      spent += outflow.amount
    }
  })

  const format = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
    minimumFractionDigits: 2,
  }).format

  let difference = income - spent

  income = format(income)
  spent = format(spent)
  difference = format(difference)

  const inflection = length === 1 ? 'transaction' : 'transactions'

  let summary = `After ${length} ${inflection}, your total is ${difference}\n`
  summary += `Your income totaled ${income}.\n`
  summary += `Your spending totaled ${spent}.\n`

  return summary
}

Quite a bit going on in there. It's mostly legible. A programmer can follow what's happening in the function, but there are related bits strewn about, a lot of mutated variables, and it's very imperative. What if that function reads like this instead?

function createBudgetSummary(
  transactions,
  summaryFormatter = defaultSummaryFormatter
) {
  const income = getIncome(transactions)
  const spent = getSpent(transactions)
  const summary = summaryFormatter({
    quantity: transactions.length,
    income,
    spent,
  })

  return summary
}

Is it easier or harder to understand this function than the previous one? Easier, right? Each line declares what's happening explicitly. Get the income. Get the expenses. Format the summary. You don't have to decipher what a bunch of lines of code, possibly scattered around the body of the function, intend to do. You just read it from top to bottom, from the function name to the function name.

But I can hear you say, "But Kyle! All you did was hide a bunch of details in functions!"

You're partly right. I did move the details elsewhere, but I didn't hide them. I encapsulated them in functions focused on that particular concern. Do you want to see? Here are the other functions:

const formatForUSD = value => {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
    minimumFractionDigits: 2,
  }).format(value)
}

const inflect = (singular, plural = singular + 's') => quantity =>
  quantity === 1 ? singular : plural

const getIncome = transactions => {
  return transactions.reduce(
    (acc, { inflow }) => (inflow ? acc + inflow.amount : acc),
    0
  )
}

const getSpent = transactions => {
  return transactions.reduce(
    (acc, { outflow }) => (outflow ? acc + outflow.amount : acc),
    0
  )
}

const defaultSummaryFormatter = ({ quantity, income, spent }) => {
  const inflection = inflect('transaction')(quantity)
  const difference = income - spent

  // I'm spltting this on 3 lines for blog legibility
  // You could do this as a multiline template string, no problem
  let summary = `After ${length} ${inflection}, your total is ${difference}\n`
  summary += `Your income totaled ${formatForUSD(income)}.\n`
  summary += `Your spending totaled ${formatForUSD(spent)}.\n`

  return summary
}

"What's so great about this?!" you exclaim. "It's more lines of code!"

True, but every function is simpler, focused, and tells you what concern it encapsulates. By writing our code this way, we gain a number of benefits:

  • Easier to digest

By focusing on encapsulation, we make our functions focused, small and containing all the elements they need to do their job. In our original code, we destructured length early on in the function body, even though it wasn't used for 15 lines or so. As a human, this adds an unnecessary strain to remember where these bits of info are. It's simpler to create functions that keep these elements tightly located where they are needed. By doing so, our code becomes more legible and more declarative. I like to think of it as the function names telling a story.

  • Mental offloading of information

This one you might disagree with, but I find if I have good functions, I don't always have to understand their details to know what's important to me at that given moment.

If I'm looking at createBudgetSummary and I'm trying to change the format of the summary, I don't need to know how the program gets the income, I just know there's a function that does getIncome. I can offload that information, or never take it on to begin with. Those details aren't important to me until I either need to change income or have found a bug in its implementation (which I can easily write a test for). I can make changes to getIncome, without changing the API of summaryFormatter.

  • Easier to refactor

Let's say you just hate Array.prototype.reduce. 2 Nothing stops you from writing:

const getIncome = transactions => {
  let total = 0

  transactions.forEach(({ inflow }) => {
    if (inflow) total += inflow.amount
  })

  return total
}

It becomes trivial to change implementation details without having an impact on other parts of the code.

  • We can recognize opportunities for flexibility more easily

Take a look at defaultSummaryFormatter. This isn't part of the original code. But once you realize what the purpose of those final lines of code are, you can see an opportunity to make our createBudgetSummary function more flexible and powerful. By using dependency injection with a default parameter, it's trivial to change what format we output, so long as the summaryFormatter makes use of the same data.

  • We gained reusable functions

We didn't set out to make reusable functions, but we got a few anyways. inflect is a highly reusable function because it encapsulates a single idea: return the right word. The way I wrote it, you can create any number of inflector functions wherever you need one.

There are even more opportunities to create further encapsulated functions. We could break our formatForUSD function and break it down into an abstraction to make more formatters, like so:

const makeCurrencyFormatter = (locale, currency) => {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
    minimumFractionDigits: 2,
  }).format
}

const formatForUSD = makeCurrencyFormatter('en-US', 'USD')

Now, you can easily make other currency formatters for other locales and currencies.

Summary

Encapsulation is the act of gathering all the elements of a single concern and containing them within a structure. In this post, we focused on functions. By encapsulating concerns and giving them good names, we made our code more declarative, easier to read, and easier to refactor. Furthermore, by properly encapsulating concerns, we found opportunities to make the code more flexible and gained even more reusability.