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