DEV Community

Samuel Rouse
Samuel Rouse

Posted on • Updated on

Abstracting Magic: JavaScript Functional Programming

One of the smallest, easiest introductions to a functional programming style is turning strings and "magic numbers" in conditions into descriptive functions. It makes your code easier to read or maintain, and it often doesn't take long.

Before We Begin

We are thinking about context in this article. Not the JavaScript context this, but developer context. It's the amount of information we have to hold in our mind to understand what the code is doing, and what it is supposed to be doing. This includes variable names and values, industry terminology, project rules, and business logic.

The complexity of developer context affects many things: new developer onboarding time, the cost of work interruptions, and the speed of defect resolution. Large developer context can directly contribute to defect risk. The more things a developer must remember, the more chances there are for misunderstandings, mistakes, and bugs.

Magic Strings & Things

Have you seen code that looks like this?

// Check the mode
if (mode === 'Insert' || mode === 'MODIFY') {
  // ...
}

// Check for a minimum level
const className = (accessLevel >= 3) ? 'primary' : 'disabled';
Enter fullscreen mode Exit fullscreen mode

Using hard-coded strings and numbers often starts out in new development with simple, clear values when the ideas are fresh and easy to remember. As time passes and new values are added, they may become cryptic or completely opaque, especially to new developers.

We refer to most numbers appearing in code as "magic numbers". We don't know why 3, just that the number causes the code to work, as if by magic.

Magic can be fun, but it does not help to understand the code you are maintaining.

  • Mode Check
    • Why is 'Insert' formatted differently than 'MODIFY'?
    • Are we guaranteed that the format will match these strings?
    • Are we sure that these two mode checks cover everything?
    • Did we add more modes since this code was written?
    • Can we understand the goal of this check?
  • Access Level
    • What is 3?!
    • How many levels are there?
    • What are the rules for each level?

Context & Experience

If you've worked on projects or platforms with a multi-decade history, you may find strings like 'VALRTCNV'. Do you read that as VAL RT CNV, VAL RTC NV, V ALRT CNV, or something else? You need context to understand it.

That value might be the result of a gradual progression from more obvious values, but it has turned into a moat that separates new developers from productivity. When a new developer encounters this string, they leave the "coding mindset" to research and obtain context. It breaks their flow. Someone has to document the meanings of the strings, explain them to each new developer, or developers may have to learn through training or certifications.

While we can sometimes eliminate these confusing values, they are often part of an application's core functionality, so each new person has to learn what they mean. There has to be a better solution, right?

Abstractions to the rescue! What if we gave clarifying names to confusing code?

Most modern computers can withstand a few more characters in our code. Let's start with names for these values. So, what does that look like?

Constants

Let's take our simple example above and add some constants.

const MODE = {
  INSERT: 'Insert',
  MODIFY: 'MODIFY',
  VIEW: 'View',
};

const PERMISSION_LEVEL = {
  GUEST:    0,
  VISITOR:  1,
  USER:     2,
  OPERATOR: 3,
  MANAGER:  4,
  ADMIN:    5,
  OWNER:    6,
};

// Check the mode
if (mode === MODE.INSERT || mode === MODE.MODIFY) {
  // ...
}

// Check for a minimum permission level
const className = (accessLevel >= PERMISSION_LEVEL.OPERATOR)
  ? 'primary' : 'disabled';
Enter fullscreen mode Exit fullscreen mode

We could make separate constants for the different modes, but grouping them helps show their relationship. A group provides a little more context.

We don't need all those PERMISSION_LEVEL entries in this one condition, but defining them all in one place makes it easy to understand the intent of selecting a level.

Externals

Once we have a decent set of constants, and we can move them into one or more files dedicated to storing these values. It makes our constants reusable and makes our logic files smaller.

import { MODE, PERMISSION_LEVEL } from 'constants';
Enter fullscreen mode Exit fullscreen mode

Context

Now that we've isolated the constants, let's go back to our code examples.

// How should we check the mode?
if (mode === MODE.INSERT || mode === MODE.MODIFY) {
  // ...
}

// Check for a minimum permission level
const className = (accessLevel >= PERMISSION_LEVEL.OPERATOR)
  ? 'primary' : 'disabled';
Enter fullscreen mode Exit fullscreen mode

The accessLevel check now has context about the intent of that condition. We can read "if the access level is greater than or equal to the 'operator' permission level...". A developer reading this code for the first time has a good chance to understand the goal.

We could still use some clarity about the mode checks, though. What is it about INSERT and MODIFY that is special? Is it because they are both editable modes, versus VIEW? Are other editable modes intentionally left out? Let's add another constant to provide context.

// How should we check the mode?
const editableMode = mode === MODE.INSERT || mode === MODE.MODIFY;

if (editableMode) {
  // ...
}

// Check for a minimum permission level
const className = (accessLevel >= PERMISSION_LEVEL.OPERATOR)
  ? 'primary' : 'disabled';
Enter fullscreen mode Exit fullscreen mode

This new variable clarifies our goals for the code with context. The difference between our first example and this is only a few characters in the conditions, but it is much more readable.

It's unlikely this is the only place these conditions are used. We can make another level of abstraction that makes it easier to consume and share these conditions, but we can't do that with just constants.

Functions

If we find ourselves checking the same conditions repeatedly, we can convert those into functions. Functions are also a good alternative to complex conditions. We can represent the idea or intent of the condition, and functions allow us to further abstract the individual operations.

import { MODE, PERMISSION_LEVEL } from 'constants';

const isModify = (mode) => mode === MODE.MODIFY;
const isInsert = (mode) => mode === MODE.INSERT;

const isAtLeastOperator = (level) => level >= PERMISSION_LEVEL.OPERATOR;

// Still a little cumbersome.
if (isInsert(mode) || isModify(mode)) {
  // ...
}

// We know exactly what it means!
const className = isAtLeastOperator(accessLevel)
  ? 'primary' : 'disabled';
Enter fullscreen mode Exit fullscreen mode

The accessLevel check is now pretty clear, but we can do a bit better for the mode. We created the constant editableMode in the last section, so let's make an equivalent function. Like the constants, we can move these to a separate file where they are available to the whole project.

import { MODE, PERMISSION_LEVEL } from 'constants';

// Simple 
export const isModify = (mode) => mode === MODE.MODIFY;
export const isInsert = (mode) => mode === MODE.INSERT;

// Compound
export const isEditable = (mode) => isInsert(mode) || isModify(mode);
Enter fullscreen mode Exit fullscreen mode

Just DRY Enough

There is a lot of disagreement in the area of code duplication. The common acronyms are DRY – Don't Repeat Yourself – that suggests you should avoid duplications, and WET – Write Everything Twice – which suggests you shouldn't worry about an abstraction until you actually use logic multiple times.

Sometimes a little duplication is appropriate. If you test for a condition in just two places, is it worth creating a file to hold the one condition and import it in two places? Is the condition complex enough that we need to extract it to simplify the developer context? Will it be used many times as development continues?

When we are talking about maintaining constants and the "low level" functions that consume them, I prefer to centralize that logic and avoid duplications and allow us to update conditions in one place so they change for everyone at once.

We can assemble these smaller pieces multiple ways. We don't need every possible combination to be centralized, though. Let's say we create the condition needsDateValidation, and right now it has the same conditions as isEditable. We might be tempted to just reuse it.

/* ** formInputs.js ** */
if (isEditable(mode) && dateChanged) {
  callDateValidation(dateValue);
}
Enter fullscreen mode Exit fullscreen mode

Using isEditable directly like this adds a tiny bit of developer context: we need to understand that editable forms are the only ones that use validation...right now. If that changes in the future, we have to remember that we used isEditable as a stand if for "needs validation".

We could duplicate the logic in isEditable, but we know that right now it's the same, so we can reexport it with a new name that matches the intention of our code, and simplifies the developer context.

/* ** utility.js ** */
// Until we need something different, re-export isEditable.
export const needsDateValidation = isEditable;

/* ** formInputs.js ** */
import { needsDateValidation } from './utility.js';

if (needsDateValidation(mode) && dateChanged) {
  callDateValidation(dateValue);
}
Enter fullscreen mode Exit fullscreen mode

If needsDateValidation changes in the future, we can manage that change inside utility.js and not have to check all the places that call isEditable to see if we were really checking for editable, or if we were using it for date validation.

Centralized Improvements

Now that we have our conditions in one place, we can solve certain types of bugs for all our consuming code at once.

In our constants file, 'Insert' and 'MODIFY' are different capitalizations. This may have been an accident, but once inconsistencies make it into the code, they can be difficult to remove. If we cannot resolve the inconsistency, we can still guard against defects by adjusting our utility functions and knowing that all the consumers benefit from the changes.

// Support any capitalization
export const isInsert = (mode) => `${mode}`.toUpperCase() === MODE.INSERT.toUpperCase();

// Validate
isInsert('Insert'); // true
isInsert('INSERT'); // true
isInsert('insert'); // true
isInsert('Modify'); // false
Enter fullscreen mode Exit fullscreen mode

Cascading Benefits

Because we built up layers of conditions, our change to isInsert automatically solves the same problem for isEditable and needsDateValidation. This process of creating "building blocks" of code and composing them into larger and more complex pieces is one of the foundations of a Function Programming style.

Long-Term Support

You'll notice when we updated isInsert we didn't change the constant 'Insert' even though we guaranteed all capitals from the variable. We left this alone so the original, intentional value is still available if we need to set a value, but we still guard against some file or service using a different capitalization.

Once we support different capitalizations across the application, we have created conditions to reconcile our constants. We can normalize our constants for clarity and maintainability, knowing that old or external values will still be handled correctly by our function.

Conclusion

Developer context is an important consideration for all coding, and
isolating "magic" numbers and constants makes code easier to understand. Taking the next step to break down a problem into smaller, well-named pieces creates a foundation you can build on. Converting imperative conditions into declarative functions can improve clarity, reusability, and often testability. Finally, it puts you in a good place to explore functional programming more deeply, if you choose.

Top comments (0)