Jargon-free functional programming. Part 1: problem statement
Basics
Let me introduce you functional programming with as few jargonisms and buzz-words as possible.
Shall we start with a simple problem to solve: get a random board game from top-10 games on BoardGamesGeek website and print out its rank and title.
BoardGameGeek website has an API: a request GET https://boardgamegeek.com/xmlapi2/hot?type=boardgame
will return an XML document like this:
<?xml version="1.0" encoding="utf-8"?>
<items termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
<item id="361545" rank="1">
<thumbnail value="https://cf.geekdo-images.com/lD8s_SQPObXTPevz-aAElA__thumb/img/YZG-deJK2vFm4NMOaniqZwwlaAE=/fit-in/200x150/filters:strip_icc()/pic6892102.png" />
<name value="Twilight Inscription" />
<yearpublished value="2022" />
</item>
<item id="276182" rank="2">
<thumbnail value="https://cf.geekdo-images.com/4q_5Ox7oYtK3Ma73iRtfAg__thumb/img/TU4UOoot_zqqUwCEmE_wFnLRRCY=/fit-in/200x150/filters:strip_icc()/pic4650725.jpg" />
<name value="Dead Reckoning" />
<yearpublished value="2022" />
</item>
</items>
In JavaScript a solution to this problem might look something like this:
fetch(`https://boardgamegeek.com/xmlapi2/hot?type=boardgame`)
.then(response => response.text())
.then(response => new DOMParser().parseFromString(response, "text/xml"))
.then(doc => {
const items = Array.from(doc.querySelectorAll('items item'));
return items.map(item => {
const rank = item.getAttribute('rank');
const name = item.querySelector('name').getAttribute('value');
return { rank, name };
});
})
.then(games => {
const randomRank = Math.floor((Math.random() * 100) % 10);
return games[randomRank];
})
.then(randomTop10Game => {
const log = `#${randomTop10Game.rank}: ${randomTop10Game.name}`;
console.log(log);
});
Quick and easy, quite easy to understand - seems good enough.
How about we write some tests for it? Oh, now it becomes a little bit clunky - we need to mock fetch
call (Fetch API) and the Math.random
. Oh, and the DOMParser
with its querySelector
and querySelectorAll
calls too. Probably even console.log
method as well. Okay, we will probably need to modify the original code to make testing easier (if even possible). How about we split the program into separate blocks of code?
const fetchAPIResponse = () =>
fetch(`https://boardgamegeek.com/xmlapi2/hot?type=boardgame`)
.then(response => response.text());
const getResponseXML = (response) =>
new DOMParser().parseFromString(response, "text/xml");
const extractGames = (doc) => {
const items = Array.from(doc.querySelectorAll('items item'));
return items.map(item => {
const rank = item.getAttribute('rank');
const name = item.querySelector('name').getAttribute('value');
return { rank, name };
});
};
const getRandomTop10Game = (games) => {
const randomRank = Math.floor((Math.random() * 100) % 10);
return games[randomRank];
};
const printGame = (game) => {
const log = `#${game.rank}: ${game.name}`;
console.log(log);
};
fetchAPIResponse()
.then(response => getResponseXML(response))
.then(doc => extractGames(doc))
.then(games => getRandomTop10Game(games))
.then(game => printGame(game));
Okay, now we can test some of the bits of the program without too much of a hassle - we could test that every call of getRandomGame
returns a different value (which might not be true) but within the given list of values. We could test the extractGames
function on a mock XML document and verify it extracts all the <item>
nodes and its <name>
child. Testing fetchAPIResponse
and getResponseXML
and printGame
functions, though, would be a bit tricky without either mocking the fetch
, console.log
and DOMParser
or actually calling those functions.
import {
fetchAPIResponse,
getResponseXML,
extractGames,
getRandomTop10Game,
printGame
} from "./index";
describe("fetchAPIResponse", () => {
describe("bad response", () => {
beforeEach(() => {
global.fetch = jest.fn(() => Promise.reject("404 Not Found"));
});
it("returns rejected promise", () => {
expect(fetchAPIResponse()).rejects.toBe("404 Not Found");
});
});
describe("ok response", () => {
beforeEach(() => {
global.fetch = jest.fn(() =>
Promise.resolve({
text() {
return `<?xml version="1.0" encoding="utf-8"?><items><item rank="1"><name value="Beyond the Sun"/></item></items>`;
}
})
);
});
it("returns rejected promise", () => {
expect(fetchAPIResponse()).resolves.toBe(
`<?xml version="1.0" encoding="utf-8"?><items><item rank="1"><name value="Beyond the Sun"/></item></items>`
);
});
});
});
describe("getResponseXML", () => {
describe("null passed", () => {
it("returns no <item> nodes", () => {
const doc = getResponseXML(null);
const items = Array.from(doc.querySelectorAll("item"));
expect(items).toHaveLength(0);
});
});
describe("invalid text passed", () => {
it("returns no <item> nodes", () => {
const doc = getResponseXML("404 not found");
const items = Array.from(doc.querySelectorAll("item"));
expect(items).toHaveLength(0);
});
});
describe("blank document passed", () => {
it("returns no <item> nodes", () => {
const doc = getResponseXML('<?xml version="1.0" encoding="utf-8"?>');
const items = Array.from(doc.querySelectorAll("item"));
expect(items).toHaveLength(0);
});
});
describe("valid document passed", () => {
it("returns <item> nodes", () => {
const doc = getResponseXML(
'<?xml version="1.0" encoding="utf-8"?><items><item rank="1"><name value="Beyond the Sun"/></item></items>'
);
const items = Array.from(doc.querySelectorAll("item"));
expect(items).toHaveLength(1);
});
});
});
describe("extractGames", () => {
describe("null document", () => {
it("throws an exception", () => {
expect(() => extractGames(null)).toThrow();
});
});
describe("empty document", () => {
it("returns empty array", () => {
const doc = new DOMParser().parseFromString("", "text/xml");
expect(extractGames(doc)).toStrictEqual([]);
});
});
describe("valid document", () => {
it("returns an array of games", () => {
const doc = new DOMParser().parseFromString(
`<?xml version="1.0" encoding="utf-8"?><items><item rank="3"><name value="Beyond the Sun"/></item></items>`,
"text/xml"
);
expect(extractGames(doc)).toStrictEqual([
{ name: "Beyond the Sun", rank: "3" }
]);
});
});
});
describe("getRandomTop10Game", () => {
describe("null passed", () => {
it("throws an exception", () => {
expect(() => getRandomTop10Game(null)).toThrow();
});
});
describe("empty array passed", () => {
it("returns undefined", () => {
expect(getRandomTop10Game([])).toStrictEqual(undefined);
});
});
describe("less than 10 element array passed", () => {
it("returns undefined", () => {
const games = [
{ name: "game1", rank: 1 },
{ name: "game2", rank: 2 }
];
const randomGames = [...new Array(100)].map(() =>
getRandomTop10Game(games)
);
expect(randomGames).toContain(undefined);
});
});
describe("10 or more element array passed", () => {
it("never returns undefined", () => {
const games = [
{ name: "game1", rank: 1 },
{ name: "game2", rank: 2 },
{ name: "game3", rank: 3 },
{ name: "game4", rank: 4 },
{ name: "game5", rank: 5 },
{ name: "game6", rank: 6 },
{ name: "game7", rank: 7 },
{ name: "game8", rank: 8 },
{ name: "game9", rank: 9 },
{ name: "game10", rank: 10 }
];
const randomGames = [...new Array(100)].map(() =>
getRandomTop10Game(games)
);
expect(randomGames).not.toContain(undefined);
});
it("returns an instance of each game", () => {
const games = [
{ name: "game1", rank: "1" },
{ name: "game2", rank: "2" },
{ name: "game3", rank: "3" },
{ name: "game4", rank: "4" },
{ name: "game5", rank: "5" },
{ name: "game6", rank: "6" },
{ name: "game7", rank: "7" },
{ name: "game8", rank: "8" },
{ name: "game9", rank: "9" },
{ name: "game10", rank: "10" }
];
const randomGames = [...new Array(100)].map(() =>
getRandomTop10Game(games)
);
expect(randomGames).toStrictEqual(expect.arrayContaining(games));
});
});
});
describe("printGame", () => {
describe("null passed", () => {
it("throws an exception", () => {
expect(() => printGame(null)).toThrow();
});
});
describe("game passed", () => {
const mockLogFn = jest.fn();
beforeEach(() => {
console.log = mockLogFn;
});
it("prints it to console", () => {
printGame({ name: "game 42", rank: "42" });
expect(mockLogFn).toHaveBeenCalledWith("#42: game 42");
});
});
});
In a lot of ways, I personally find these tests quite… hacky. But they seem to cover most of the functionality.
Let us talk about corner cases. As in, what would happen if the API does not return the result? Or what would happen if the result is not a valid XML (like 404 Not Found
text)? Or what would happen if the XML is valid, but it does not contain any items
or item[rank]>name[value]
nodes? Or what if it only returns 5
results (or any number of results less than 10
, for that matter)?
In most of the cases, the promise will get rejected (since an entire program is a chain of Promise.then
calls). So you might think this is just fine and rely on the rejection logic handling (maybe even using Promise.catch
).
If you want to be smart about these error cases, you would need to introduce the checks to each and every step of the chain.
In “classic” JS or TS you might want to return null
(or, less likely, use Java-style approach, throwing an exception) when the error occurs.
This, however, comes with the need to introduce the null
checks all over the place.
Consider this refactoring:
const fetchAPIResponse = () =>
fetch(`https://boardgamegeek.com/xmlapi2/hot?type=boardgame`)
.then(response => response.text());
const getResponseXML = (response) => {
try {
return new DOMParser().parseFromString(response, "text/xml");
} catch {
return null;
}
};
const extractGames = (doc) => {
if (!doc) {
return null;
}
const items = Array.from(doc.querySelectorAll('items item'));
return items.map(item => {
const rank = item.getAttribute('rank');
const name = item.querySelector('name').getAttribute('value');
return { rank, name };
});
};
const getRandomTop10Game = (games) => {
if (!games) {
return null;
}
if (games.length < 10) {
return null;
}
const randomRank = Math.floor((Math.random() * 100) % 10);
return games[randomRank];
};
const printGame = (game) => {
if (!game) {
return null;
}
const log = `#${game.rank}: ${game.name}`;
console.log(log);
};
fetchAPIResponse()
.then(response => getResponseXML(response))
.then(doc => extractGames(doc))
.then(games => getRandomTop10Game(games))
.then(game => printGame(game));
In case you don’t want to bother with null
values or want to have a better logging (not necessarily error handling), you can straight away throw an exception:
const fetchAPIResponse = () =>
fetch(`https://boardgamegeek.com/xmlapi2/hot?type=boardgame`)
.then(response => response.text());
const getResponseXML = (response) => {
try {
return new DOMParser().parseFromString(response, "text/xml");
} catch {
throw 'Received invalid XML';
}
};
const extractGames = (doc) => {
const items = Array.from(doc.querySelectorAll('items item'));
return items.map(item => {
const rank = item.getAttribute('rank');
const name = item.querySelector('name').getAttribute('value');
return { rank, name };
});
};
const getRandomTop10Game = (games) => {
if (!games) {
throw 'No games found';
}
if (games.length < 10) {
throw 'Less than 10 games received';
}
const randomRank = Math.floor((Math.random() * 100) % 10);
return games[randomRank];
};
const printGame = (game) => {
if (!game) {
throw 'No game provided';
}
const log = `#${game.rank}: ${game.name}`;
console.log(log);
};
fetchAPIResponse()
.then(response => getResponseXML(response))
.then(doc => extractGames(doc))
.then(games => getRandomTop10Game(games))
.then(game => printGame(game))
.catch(error => console.error('Failed', error));
Alternatively, since an entire program is a chain of promises, you could just return a rejected promise:
const fetchAPIResponse = () =>
fetch(`https://boardgamegeek.com/xmlapi2/hot?type=boardgame`)
.then(response => response.text());
const getResponseXML = (response) => {
try {
return new DOMParser().parseFromString(response, "text/xml");
} catch {
return Promise.reject('Received invalid XML');
}
};
const extractGames = (doc) => {
const items = Array.from(doc.querySelectorAll('items item'));
return items.map(item => {
const rank = item.getAttribute('rank');
const name = item.querySelector('name').getAttribute('value');
return { rank, name };
});
};
const getRandomTop10Game = (games) => {
if (!games) {
return Promise.reject('No games found');
}
if (games.length < 10) {
return Promise.reject('Less than 10 games received');
}
const randomRank = Math.floor((Math.random() * 100) % 10);
return games[randomRank];
};
const printGame = (game) => {
if (!game) {
return Promise.reject('No game provided');
}
const log = `#${game.rank}: ${game.name}`;
console.log(log);
};
fetchAPIResponse()
.then(response => getResponseXML(response))
.then(doc => extractGames(doc))
.then(games => getRandomTop10Game(games))
.then(game => printGame(game))
.catch(error => console.error('Failed', error));
That’s all good and nice and we seem to have covered most of the edge case scenarios (at least those we could think of).
Now, what if I tell you the program is still not entirely correct? See those querySelector
calls? They might return null
if the node
or the attribute is not present. And we do not want those empty objects in our program’ output. This might be tricky to catch immediately
while developing the code.
One might even argue that most of those errors would have been caught by the compiler, if we have used something like TypeScript. And they might be right - for the most part:
interface Game {
name: string;
rank: string;
}
const fetchAPIResponse = () =>
fetch(`https://boardgamegeek.com/xmlapi2/hot?type=boardgame`)
.then(response => response.text());
const getResponseXML = (response: string) => {
try {
return new DOMParser().parseFromString(response, "text/xml");
} catch {
throw 'Received invalid XML';
}
};
const extractGames = (doc: XMLDocument) => {
const items = Array.from(doc.querySelectorAll('items item'));
return items.map(item => {
const rank = item.getAttribute('rank') ?? '';
const name = item.querySelector('name')?.getAttribute('value') ?? '';
return { rank, name };
});
};
const getRandomTop10Game = (games: Array<Game>) => {
if (!games) {
throw 'No games found';
}
if (games.length < 10) {
throw 'Less than 10 games received';
}
const randomRank = Math.floor((Math.random() * 100) % 10);
return games[randomRank];
};
const printGame = (game: Game) => {
if (!game) {
throw 'No game provided';
}
const log = `#${game.rank}: ${game.name}`;
console.log(log);
};
fetchAPIResponse()
.then(r => getResponseXML(r))
.then(doc => extractGames(doc))
.then(games => getRandomTop10Game(games))
.then(game => printGame(game))
.catch(error => console.error('Failed', error));
Not too many changes, but those pesky little errors were caught at development time, pretty much. The testing is still a challenge, though.
There is a application design approach which might be able to solve quite a bit of the aforementioned issues. Let me introduce you to the world of functional programming without a ton of buzzwords and overwhelming terminology.