Destructured Function Parameters and Code Maintainability

Arturo Martínez
The Startup
Published in
5 min readAug 20, 2020
Photo by Kin Li on Unsplash

Code keeps changing, there’s no doubt about that. We always do our best to set some rock solid code foundations when a new project starts, we even have one or two favorite functions that become the cornerstone of our beautiful program. “This function will be my rod when I walk through the valley of the shadow of death.” But requirements change, and now your function should not just be the rod, but the rod and the staff as well.

Let’s take a walk.

Turn 1

Let’s imagine a MUD written in Node.js. At some point, we will be implementing melee combat in our game. And you write a function you are very much in love with; it’s a function to calculate the damage inflicted by a melee weapon. For the sake of storytelling, we don’t really care much about the contents of the function. We assume you just love it.

Turn 2

After a couple of days, a game mechanics designer asks you to make some changes in the function because the weapon damage calculation should optionally take into account the physical defenses of the player who is being attacked. Turns out this function is being used both to display average damages in the UI, and to actually calculate the damage in-game when used against a real player. So, sometimes there will be a defender, and sometimes not.

Well, fine! I guess we can add a new argument to the function. We can even try to make the function signature backwards compatible by checking whether the new argument is being passed on or not, so that we do not have to go around refactoring all the usages of calculateWeaponDamage.

Again, for the sake of storytelling, let’s ignore the fact that this example might be architecturally all wrong.

Settled down for now, aren’t we?

Turn 3

Hey! We are giving players with extreme strength some melee damage bonus. Do you think you can take this into account in your beloved calculateWeaponDamage function? All developers using your function will be sending you an instance of the player wielding the weapon, because we need this extra damage to be reflected both in the UI and the actual in-game calculations.

— Game mechanics designer

OK, fine. Now we have three arguments: two mandatory, one optional. But it turns out that other developers don’t have time to do a proper refactoring of the usages of calculateWeaponDamage and, out of desperation, agree to call the function even with an optional argument surrounded by two mandatory ones: calculateDamage(weapon, null, attacker);.

This might be far from ideal.

At this point you start to feel a bit itchy.

Turn 4

You feel they are misusing calculateWeaponDamage. You are tired of changing the signature of the function and decide to try a new approach: let’s use destructured function parameters. That way, the function receives a single options object argument holding as many mandatory or parameters as they want; if, in the future, new parameters should be added, then the refactoring of its usages will not be as painful as before, and as the owner of the function I don’t have to break my head too much about its signature — it’s just adding another property to the single object argument.

Turn 5

Change of plans, bud!

We need a bunch of extra options in calculateWeaponDamage: weapons might have imbued elemental damage, so we need a flag to determine whether we should take those into account, we also need a flag to actually inflict the damage on defender instead of just calculating it, and we also […]

— Game mechanics designer

Anyway. For some reason or another, they’ve thrown a pile of flags at our function and now its signature just became something like this:

Would you be happy with this function signature?

Retrospective

I’ve found myself guilty of overusing destructured function parameters. Sometimes they felt really convenient, other times those function signatures stuck out like a sore thumb.

The series of unfortunate events that led to the latest calculateWeaponDamage version are probably exaggerated, but hopefully you might have recognized some personal experiences or work patterns in them. With the knowledge we have at this point, this is the function signature I would have proposed:

  • attacker, weapon and defender are the main actors in this function. Semantically, they “weight” more than all the other parameters in the signature. They have the right to be their own arguments in the function.
  • We know now that the function will receive an actual set of options. Let’s have those as a separate, namesake object argument. Traditionally, the “options” argument is the last one, but in our scenario we have an optional argument in between: defender.

If we would attempt to do some sort of function overloading by allowing our function to be called in both these ways…

  • calculateWeaponDamage(attacker, weapon, { ... });
  • calculateWeaponDamage(attacker, weapon, defender, { ... });

…you’ll end up having errors when using the first example (without defender) because there would be an attempt to destructure an undefined 4th argument:

> calculateWeaponDamage(fingonfil, sword, { criticalHitsAllowed: false });
Uncaught TypeError: Cannot read property 'inflictInDefender' of undefined

Maybe our function is just not that suitable to have destructured parameters. Still, we could solve the aforementioned error with a more exotic signature:

This way we “won’t” run into TypeError exceptions when there is no defender passed to the function (narrator: we still will.)

Because our function is about calculating the damage inflicted by a weapon, weapon deserves to be the first argument. attacker and defender, as the interactive parties involved in the combat, have the same “semantic weight”. We could group them together as the second destructured argument.

Unfortunately, if we go for the simplest possible usage of this new function…

> calculateWeaponDamage(sword, { attacker: fingonfil });

…we are still getting a TypeError exception.

> calculateWeaponDamage(sword, { attacker: fingonfil })
Uncaught TypeError: Cannot read property 'inflictInDefender' of undefined
at calculateWeaponDamage (repl:2:7)

Since the argument options is truly optional, we can default its value to {}, like this:

And this my dear reader would be our last version of the function before we give up and move to TypeScript.

What have we noticed?

  • Sometimes we will spend an outrageous amount of time trying to get the right name of a function, or an argument. Or pondering on the position of an argument goes. That’s fine.
  • It helps when the arguments of a function have a semantic meaning and can be weighed. Some of them deserve to be in the spotlight, others can stay more in the backstage area.
  • Destructured function parameters can help with maintainability but also come with some challenges, like we saw. It’s good to ask oneself: do I really need them here? Is it helping me or making things more complicated?
  • There is a certain convention on what arguments should be in the last position, like callbacks and option objects. If some of those things are present in your function, other developers might expect them to be in that certain position.

We are documenting our functions with JSDoc, which will also help us see which arguments are optional and which ones have default values. For a more detailed article on bringing your JSDdoc skillsto the next level, check out one of my previous articles:

--

--