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:
- result
- totalAmount
- 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);