All posts
JavascriptTypescriptClean CodeClean Architecture

JavaScript Refactoring

Paul Allies
JavaScript Refactoring

In Martin Fowlers free chapter in his second edition on Refactoring, he goes through an example function to illustrate refactoring. I’ll attempt to suggest a more functional way of doing the same refactor.

The function prints a bill from a theatre company for all plays performed in front audiences. The output of the function is as follows:

Statement for BigCo
 Hamlet: $650.00 (55 seats)
 As You Like It: $580.00 (35 seats)
 Othello: $500.00 (40 seats)
Amount owed is $1,730.00
You earned 47 credits

The function prints a bill in text. We’d like to keep its functionality and make it more flexible to printing in other formats as well.

function statement(invoice) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = "";
  let result += `Statement for ${invoice.customer}\n`;
  const format = new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: 2
  }).format;
  for (let perf of invoice.performances) {
    const play = plays[perf.playID];
    let thisAmount = 0;
    switch (play.type) {
      case "tragedy":
        thisAmount = 40000;
        if (perf.audience > 30) {
          thisAmount += 1000 * (perf.audience - 30);
        }
        break;
      case "comedy":
        thisAmount = 30000;
        if (perf.audience > 20) {
          thisAmount += 10000 + 500 * (perf.audience - 20);
        }
        thisAmount += 300 * perf.audience;
        break;
      default:
        throw new Error(`unknown type: ${play.type}`);
    }
    // add volume credits
    volumeCredits += Math.max(perf.audience - 30, 0);
    // add extra credit for every ten comedy attendees
    if ("comedy" === play.type) volumeCredits += Math.floor(perf.audience / 5);
    // print line for this order
    result += ` ${play.name}: ${format(thisAmount / 100)} (${
      perf.audience
    } seats)\n`;
    totalAmount += thisAmount;
  }
  result += `Amount owed is ${format(totalAmount / 100)}\n`;
  result += `You earned ${volumeCredits} credits`;
  return result;
}

Given the datasources for invoices and plays

//plays.json
{
  "hamlet": { "name": "Hamlet", "type": "tragedy" },
  "as-like": { "name": "As You Like It", "type": "comedy" },
  "othello": { "name": "Othello", "type": "tragedy" }
}
//invoices.json
[
  {
    "customer": "BigCo",
    "performances": [
      {
        "playID": "hamlet",
        "audience": 55
      },
      {
        "playID": "as-like",
        "audience": 35
      },
      {
        "playID": "othello",
        "audience": 40
      }
    ]
  }
]

Looking at the output I try to see structure. I see a header, lines and a footer. so our target is:

function statement(invoice) {
  let totalAmount = calculateTotalAmount(invoice);
  let volumeCredits = calculateVolumeCredits(invoice);
  let result = renderHeader(invoice.customer);
  result += renderLines(invoice);
  result += renderFooter(totalAmount, volumeCredits);
  return result;
}
function renderHeader(customer) {
  return `Statement for ${customer}\n`;
}

For UI functions I normally prefix the function with render…

There are a few things we can immediately refactor out of the statement function:

function formatUSD(val) {
  const format = new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: 2
  }).format;
  return format(val);
}
function getPlayById(playId) {
  if (plays[playId]) {
    return plays[playId];
  } else {
    throw new Error("Play Not Found");
  }
}

Next, I see a loop which builds a few things:

  1. result
  2. totalAmount
  3. volumeCredits

using what Martins split loop, let’s create functions to compute each of these results.

function renderLines(invoice) {
  let result = "";
  for (let perf of invoice.performances) {
    const play = getPlayById(perf.playID);
    let thisAmount = 0;
    switch (play.type) {
      case "tragedy":
        thisAmount = 40000;
        if (perf.audience > 30) {
          thisAmount += 1000 * (perf.audience - 30);
        }
        break;
      case "comedy":
        thisAmount = 30000;
        if (perf.audience > 20) {
          thisAmount += 10000 + 500 * (perf.audience - 20);
        }
        thisAmount += 300 * perf.audience;
        break;
      default:
        throw new Error(`unknown type: ${play.type}`);
    }
    // print line for this order
    result += ` ${play.name}: ${formatUSD(thisAmount / 100)} (${
      perf.audience
    } seats)\n`;
  }
  return result;
}
function calculateTotalAmount(invoice) {
  let totalAmount = 0;
  for (let perf of invoice.performances) {
    const play = getPlayById(perf.playID);
    let thisAmount = 0;
    switch (play.type) {
      case "tragedy":
        thisAmount = 40000;
        if (perf.audience > 30) {
          thisAmount += 1000 * (perf.audience - 30);
        }
        break;
      case "comedy":
        thisAmount = 30000;
        if (perf.audience > 20) {
          thisAmount += 10000 + 500 * (perf.audience - 20);
        }
        thisAmount += 300 * perf.audience;
        break;
      default:
        throw new Error(`unknown type: ${play.type}`);
    }
    totalAmount += thisAmount;
  }
  return totalAmount;
}
function calculateVolumeCredits(invoice) {
  let volumeCredits = 0;
  for (let perf of invoice.performances) {
    const play = getPlayById(perf.playID);
    volumeCredits += Math.max(perf.audience - 30, 0);
    if ("comedy" === play.type) volumeCredits += Math.floor(perf.audience / 5);
  }
  return volumeCredits;
}

Because the same switch statement is used by both calculateAmount and renderBody, let’s refactor that out

function calculatePlayAmount(playType, audience) {
  let thisAmount = 0;
  switch (playType) {
    case "tragedy":
      thisAmount = 40000;
      if (audience > 30) {
        thisAmount += 1000 * (audience - 30);
      }
      break;
    case "comedy":
      thisAmount = 30000;
      if (audience > 20) {
        thisAmount += 10000 + 500 * (audience - 20);
      }
      thisAmount += 300 * audience;
      break;
    default:
      throw new Error(`unknown type: ${playType}`);
  }
  return thisAmount;
}

let’s simplify our calculatePlayAmount to allow us to add more playTypes

function calculatePlayAmount(playType) {
  let rules = {
    tragedy(audience) {
      let thisAmount = 40000;
      if (audience > 30) {
        thisAmount += 1000 * (audience - 30);
      }
      return thisAmount;
    },
    comedy(audience) {
      let thisAmount = 30000;
      if (audience > 20) {
        thisAmount += 10000 + 500 * (audience - 20);
      }
      thisAmount += 300 * audience;
      return thisAmount;
    }
  };
  if (rules[playType]) {
    return rules[playType];
  } else {
    throw new Error(`unknown type: ${playType}`);
  }
}

we can now revise our calculateTotalAmount and renderLines

function calculateTotalAmount(invoice) {
  let totalAmount = 0;
  for (let perf of invoice.performances) {
    const play = getPlayById(perf.playID);
    totalAmount += calculatePlayAmount(play.type)(perf.audience);
  }
  return totalAmount;
}
function renderLines(invoice) {
  let result = "";
  for (let perf of invoice.performances) {
    const play = getPlayById(perf.playID);
    let thisAmount = calculatePlayAmount(play.type)(perf.audience);
    result += ` ${play.name}: ${formatUSD(thisAmount / 100)} (${
      perf.audience
    } seats)\n`;
  }
  return result;
}

We can further refactor the state function

function renderFooter(totalAmount, volumeCredits) {
  let result = "";
  result += `Amount owed is ${formatUSD(totalAmount / 100)}\n`;
  result += `You earned ${volumeCredits} credits`;
  return result;
}

function statement(invoice) {
  let result = renderHeader(invoice.customer);
  result += renderLines(invoice);
  let totalAmount = calculateTotalAmount(invoice);
  let volumeCredits = calculateVolumeCredits(invoice);
  result += renderFooter(totalAmount, volumeCredits);
  return result;
}

Now let’s say we want to create HTML renderFunctions we can do so independently and inject them into the statement function as follows:

function statement(invoice, renderHeader, renderLines, renderFooter) {
  let result = renderHeader(invoice.customer);
  result += renderLines(invoice);
  let totalAmount = calculateTotalAmount(invoice);
  let volumeCredits = calculateVolumeCredits(invoice);
  result += renderFooter(totalAmount, volumeCredits);
  return result;
}

We can now invoke the function with other render functions

statement(invoice, renderHTMLHeader, renderHTMLLines, renderHTMLFooter);
statement(invoice, renderMDHeader, renderMDLines, renderMDFooter);