JavaScript Refactoring
Photo by Alex Andrews from Pexels

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.

1function statement(invoice) { 2 let totalAmount = 0; 3 let volumeCredits = 0; 4 let result = ""; 5 let result += `Statement for ${invoice.customer}\n`; 6 const format = new Intl.NumberFormat("en-US", { 7 style: "currency", 8 currency: "USD", 9 minimumFractionDigits: 2 10 }).format; 11 for (let perf of invoice.performances) { 12 const play = plays[perf.playID]; 13 let thisAmount = 0; 14 switch (play.type) { 15 case "tragedy": 16 thisAmount = 40000; 17 if (perf.audience > 30) { 18 thisAmount += 1000 * (perf.audience - 30); 19 } 20 break; 21 case "comedy": 22 thisAmount = 30000; 23 if (perf.audience > 20) { 24 thisAmount += 10000 + 500 * (perf.audience - 20); 25 } 26 thisAmount += 300 * perf.audience; 27 break; 28 default: 29 throw new Error(`unknown type: ${play.type}`); 30 } 31 // add volume credits 32 volumeCredits += Math.max(perf.audience - 30, 0); 33 // add extra credit for every ten comedy attendees 34 if ("comedy" === play.type) volumeCredits += Math.floor(perf.audience / 5); 35 // print line for this order 36 result += ` ${play.name}: ${format(thisAmount / 100)} (${ 37 perf.audience 38 } seats)\n`; 39 totalAmount += thisAmount; 40 } 41 result += `Amount owed is ${format(totalAmount / 100)}\n`; 42 result += `You earned ${volumeCredits} credits`; 43 return result; 44}

Given the datasources for invoices and plays

1//plays.json 2{ 3 "hamlet": { "name": "Hamlet", "type": "tragedy" }, 4 "as-like": { "name": "As You Like It", "type": "comedy" }, 5 "othello": { "name": "Othello", "type": "tragedy" } 6}
1//invoices.json 2[ 3 { 4 "customer": "BigCo", 5 "performances": [ 6 { 7 "playID": "hamlet", 8 "audience": 55 9 }, 10 { 11 "playID": "as-like", 12 "audience": 35 13 }, 14 { 15 "playID": "othello", 16 "audience": 40 17 } 18 ] 19 } 20]

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

1function statement(invoice) { 2 let totalAmount = calculateTotalAmount(invoice); 3 let volumeCredits = calculateVolumeCredits(invoice); 4 let result = renderHeader(invoice.customer); 5 result += renderLines(invoice); 6 result += renderFooter(totalAmount, volumeCredits); 7 return result; 8}
1function renderHeader(customer) { 2 return `Statement for ${customer}\n`; 3}

For UI functions I normally prefix the function with render...

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

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

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.

1function renderLines(invoice) { 2 let result = ""; 3 for (let perf of invoice.performances) { 4 const play = getPlayById(perf.playID); 5 let thisAmount = 0; 6 switch (play.type) { 7 case "tragedy": 8 thisAmount = 40000; 9 if (perf.audience > 30) { 10 thisAmount += 1000 * (perf.audience - 30); 11 } 12 break; 13 case "comedy": 14 thisAmount = 30000; 15 if (perf.audience > 20) { 16 thisAmount += 10000 + 500 * (perf.audience - 20); 17 } 18 thisAmount += 300 * perf.audience; 19 break; 20 default: 21 throw new Error(`unknown type: ${play.type}`); 22 } 23 // print line for this order 24 result += ` ${play.name}: ${formatUSD(thisAmount / 100)} (${ 25 perf.audience 26 } seats)\n`; 27 } 28 return result; 29}
1function calculateTotalAmount(invoice) { 2 let totalAmount = 0; 3 for (let perf of invoice.performances) { 4 const play = getPlayById(perf.playID); 5 let thisAmount = 0; 6 switch (play.type) { 7 case "tragedy": 8 thisAmount = 40000; 9 if (perf.audience > 30) { 10 thisAmount += 1000 * (perf.audience - 30); 11 } 12 break; 13 case "comedy": 14 thisAmount = 30000; 15 if (perf.audience > 20) { 16 thisAmount += 10000 + 500 * (perf.audience - 20); 17 } 18 thisAmount += 300 * perf.audience; 19 break; 20 default: 21 throw new Error(`unknown type: ${play.type}`); 22 } 23 totalAmount += thisAmount; 24 } 25 return totalAmount; 26}
1function calculateVolumeCredits(invoice) { 2 let volumeCredits = 0; 3 for (let perf of invoice.performances) { 4 const play = getPlayById(perf.playID); 5 volumeCredits += Math.max(perf.audience - 30, 0); 6 if ("comedy" === play.type) volumeCredits += Math.floor(perf.audience / 5); 7 } 8 return volumeCredits; 9}

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

1function calculatePlayAmount(playType, audience) { 2 let thisAmount = 0; 3 switch (playType) { 4 case "tragedy": 5 thisAmount = 40000; 6 if (audience > 30) { 7 thisAmount += 1000 * (audience - 30); 8 } 9 break; 10 case "comedy": 11 thisAmount = 30000; 12 if (audience > 20) { 13 thisAmount += 10000 + 500 * (audience - 20); 14 } 15 thisAmount += 300 * audience; 16 break; 17 default: 18 throw new Error(`unknown type: ${playType}`); 19 } 20 return thisAmount; 21}

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

1function calculatePlayAmount(playType) { 2 let rules = { 3 tragedy(audience) { 4 let thisAmount = 40000; 5 if (audience > 30) { 6 thisAmount += 1000 * (audience - 30); 7 } 8 return thisAmount; 9 }, 10 comedy(audience) { 11 let thisAmount = 30000; 12 if (audience > 20) { 13 thisAmount += 10000 + 500 * (audience - 20); 14 } 15 thisAmount += 300 * audience; 16 return thisAmount; 17 } 18 }; 19 if (rules[playType]) { 20 return rules[playType]; 21 } else { 22 throw new Error(`unknown type: ${playType}`); 23 } 24}

we can now revise our calculateTotalAmount and renderLines

1function calculateTotalAmount(invoice) { 2 let totalAmount = 0; 3 for (let perf of invoice.performances) { 4 const play = getPlayById(perf.playID); 5 totalAmount += calculatePlayAmount(play.type)(perf.audience); 6 } 7 return totalAmount; 8}
1function renderLines(invoice) { 2 let result = ""; 3 for (let perf of invoice.performances) { 4 const play = getPlayById(perf.playID); 5 let thisAmount = calculatePlayAmount(play.type)(perf.audience); 6 result += ` ${play.name}: ${formatUSD(thisAmount / 100)} (${ 7 perf.audience 8 } seats)\n`; 9 } 10 return result; 11}

We can further refactor the state function

1function renderFooter(totalAmount, volumeCredits) { 2 let result = ""; 3 result += `Amount owed is ${formatUSD(totalAmount / 100)}\n`; 4 result += `You earned ${volumeCredits} credits`; 5 return result; 6} 7 8function statement(invoice) { 9 let result = renderHeader(invoice.customer); 10 result += renderLines(invoice); 11 let totalAmount = calculateTotalAmount(invoice); 12 let volumeCredits = calculateVolumeCredits(invoice); 13 result += renderFooter(totalAmount, volumeCredits); 14 return result; 15}

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

1function statement(invoice, renderHeader, renderLines, renderFooter) { 2 let result = renderHeader(invoice.customer); 3 result += renderLines(invoice); 4 let totalAmount = calculateTotalAmount(invoice); 5 let volumeCredits = calculateVolumeCredits(invoice); 6 result += renderFooter(totalAmount, volumeCredits); 7 return result; 8}

We can now invoke the function with other render functions

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