Dots on a screen.

Breaking out of the Black Box – Refactoring Verbose Functions

Cover Image for Breaking out of the Black Box – Refactoring Verbose Functions

In Eric Raymond's seminal work, "The Art of Unix Programming" (1999-2003), he outlines the Unix philosophy, a set of common-sense principles that are relevant to anyone involved in software development, whether on the front-end or back-end. These principles serve as guiding lights for software developers:

  • Rule of Modularity: Write simple parts connected by clean interfaces.
  • Rule of Clarity: Clarity is better than cleverness.
  • Rule of Composition: Design programs to be connected to other programs.
  • Rule of Separation: Separate policy from mechanism; separate interfaces from engines.
  • Rule of Simplicity: Design for simplicity; add complexity only where you must.
  • Rule of Parsimony: Write a big program only when it is clear by demonstration that nothing else will do.
  • Rule of Transparency: Design for visibility to make inspection and debugging easier.
  • Rule of Robustness: Robustness is the child of transparency and simplicity.
  • Rule of Representation: Fold knowledge into data so program logic can be stupid and robust.
  • Rule of Least Surprise: In interface design, always do the least surprising thing.
  • Rule of Silence: When a program has nothing surprising to say, it should say nothing.
  • Rule of Repair: When you must fail, fail noisily and as soon as possible.
  • Rule of Economy: Programmer time is expensive; conserve it in preference to machine time.
  • Rule of Generation: Avoid hand-hacking; write programs to write programs when you can.
  • Rule of Optimization: Prototype before polishing. Get it working before you optimize it.
  • Rule of Diversity: Distrust all claims for “one true way”.
  • Rule of Extensibility: Design for the future, because it will be here sooner than you think.

While these principles can be applied at a macro level to entire applications, systems, or even programming languages, they can also be applied at a micro level to individual functions.

The Problem: Black Box Functions

At a macro level, separating concerns enhances code maintainability and readability. However, it's equally essential to consider separation of concerns at a micro level. We should steer clear of creating code 'black boxes' — lengthy functions filled with subroutines that manipulate inputs in a convoluted and opaque manner before producing their outputs.

Let's examine a typical example of a vanilla TypeScript component. This component takes an object in the shape of the componentData type, which can be thought of as parsed JSON from a backend service call. While this code isn't the worst, it can certainly be improved:

export type componentData = {
    date: string;
    currency: "$" | "£" | "€";
    cost:   number;
    text: string;
    subtext?: string;
    created: string;
    lastModified: string;
}

export type Component = (target:HTMLElement, data:componentData) => HTMLElement;

/**
 * Black box component that renders a div with the following structure:
 *  <div>
 *     <div class="bad-component text">data.text</div>
 *     <div class="bad-component subtext">data.subtext</div>
 *     <div class="bad-component date">data.date</div>
 *     <div class="bad-component cost">data.currency + data.cost</div>
 * </div> 
 * 
 * @param target HTMLElement
 * @param data componentData
 * @returns HTMLElement
 */
export const BadComponent: Component = (target:HTMLElement, data:componentData):HTMLElement => {
    // ...
    // Implementation details here
    // ...
    return div;
};

While this code is functional, it has several issues:

  • Only the final output can be tested, not the individual working parts.

  • Functions are run in-line, making the logic harder to follow.

  • The code is tightly coupled to a specific implementation, limiting reusability.

The Solution: Refactoring the Black Boxes

In the software development world, it's time to rethink old practices. Just as Domain Driven Design (DDD) is applied on the backend to break down monolithic code into manageable, self-contained modules, a similar approach can be taken on the frontend to identify and extract discrete business logic.

Looking at the black-box code example above, there are clear opportunities to refactor the code by breaking it into reusable utility functions. One key area for improvement is the data transformation that occurs when handling data from a service call.

The goal is to transform data like this:

const data = {
    date: "2020-01-06",
    currency: "$",
    cost: 1000,
    text: "hello root",
    subtext: "hello subtext",
    created: "2020-01-06",
    lastModified: "2023-01-09"
}

Into this:

const data = {
    date: "01/06/2020",
    cost: "$1,000.25",
    text: "hello root",
    subtext: "hello subtext",
}

We can extract the business logic responsible for this data transformation into a separate function:

/**
 * The Business Logic of the black box component
 * @param data componentData
 * @returns parsedData
 */
export const parseData = (data:componentData):parsedData => {
    const {text, currency, cost, subtext} = data;
    const [int, decimal] = cost.toFixed(2).split('.');
    const costStringFormatted = `${currency}${parseInt(int).toLocaleString("en-US")}.${String(decimal)}`; 
    
    return {
        date: data.date.split('-').reverse().join('/'),
        cost: costStringFormatted,
        text: text,
        subtext: subtext
    };   
}

This separation of concerns allows us to create cleaner, more modular code. It also makes the business logic framework-agnostic, enabling it to be easily adapted for different JavaScript component libraries like React, Vue, or Svelte.

More Granular Separation of Concerns – Write Everything Like a Library

To achieve even greater separation of concerns, it's advisable to write code as if it were a reusable library, even if you're convinced it will only be used in a single application. Try to avoid dependencies on external libraries and versions whenever possible. Make your code stand alone and testable.

In the dataParser function, there are subroutines that could be further separated into distinct utility functions, such as date formatting and currency formatting.

Currency formatting can be broken down into even more granular concerns, including number formatting (adding commas and two decimal places) and adding the currency notation. By isolating these concerns, we can create more testable and maintainable code.

Here's an example:

export type numberToFixed2 = `${string}.${string}` ;
export type currency = "$" | "£" | "€";
export type currencyFormat = `${currency}${numberToFixed2}` ;

/**
 * Formats a number to a string with 2 decimals
 * @param num number
 * @returns numberToFixed2
 */
export const formatNumberToFixed2

 = (num:number):numberToFixed2 => {
    const [int, decimal] = num.toFixed(2).split('.');
    return `${parseInt(int).toLocaleString("en-US")}.${String(decimal)}`; 
};

/** 
 * Formats a number to a string with 2 decimals and a currency symbol
 * @param num number
 * @param currency currency
 * @returns currencyFormat
 */
export const formatCurrency = (num:number, currency:currency):currencyFormat => `${currency}${formatNumberToFixed2(num)}`

Each of these functions can be individually tested, and the code becomes increasingly composed of smaller, reusable functions, aligning with Unix principles such as Modularity, Clarity, Composition, Separation, Simplicity, Parsimony, Transparency, and Robustness.

Refactoring the Display Component

The display component can also benefit from refactoring. The process of creating and attaching div elements is a repetitive task that can be refactored into a separate function:

/**
 * Creates a div with the following structure:
 * <div class="className">innerHTML</div>
 * and appends it to the parent div
 * @param className string
 * @param innerHTML string
 * @param parent HTMLDivElement
 * @returns HTMLDivElement
 */
const makeAndAttachDiv = (className:string, innerHTML:string, parent: HTMLDivElement):HTMLDivElement => {
    const div = document.createElement('div');
    div.className = className;
    div.innerHTML = innerHTML;
    parent.appendChild(div);
    return div;
};

/**
 * Creates a display component
 * and appends it to the target div
 * @param target HTMLElement
 * @param data parsedData
 * @returns HTMLElement
 */
const displayLogicComponentRefactored = (target:HTMLElement, data:parsedData):HTMLElement => {
    const {text, subtext, date, cost} = data;
    const div = document.createElement('div');
    makeAndAttachDiv("display-component text", text, div);
    subtext && makeAndAttachDiv("display-component subtext", subtext, div);
    makeAndAttachDiv("display-component date", date, div);
    makeAndAttachDiv("display-component cost", cost, div);
    target.appendChild(div);
    return div;
}

This refactoring not only makes the code cleaner but also follows the Unix philosophy of modularity and simplicity. Furthermore, with a bit more work, the Component function can be made more versatile, allowing it to take an object or array description of DOM structure and return HTMLElements with varying DOM structures, fulfilling the Rule of Extensibility.

Test Driven Development (TDD)

This approach aligns seamlessly with Test Driven Development (TDD). Adopting a "write-the-tests-first" approach encourages a mindset that identifies the smaller elements of a function as it's being written. When refactoring lengthy functions, the sub-functions can be developed and tested using TDD before integrating them into the longer function.

TDD, especially when coupled with a functional programming approach that emphasizes "pure functions," results in cleaner, more legible code. It brings us closer to fulfilling the Unix principles of Modularity, Clarity, Composition, Separation, Simplicity, Parsimony, Transparency, and Robustness. Additionally, it allows us to design for the future, preventing our codebase from succumbing to the weight of technical debt.

In conclusion, breaking out of the black box mindset and embracing the Unix philosophy can significantly enhance code quality and maintainability. By refactoring verbose functions, separating concerns, and adopting practices like TDD, developers can create more modular, readable, and robust software. In a rapidly evolving tech landscape, adhering to these principles ensures that our code remains adaptable and resilient to change.