Code Conservatory
JavaScript Refactoring

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);