Default values can be a blessing, saving developers time and effort in common use cases, but if they are not chosen with care they can also become a curse. Here we will take a closer look at some of the issues that can arise when defaults are at fault.

To speak no word that is not true

While the above motto applies to Aes Sedai, it should not apply to default values. Consider this case, using JavaScript as an example of a language which supports default arguments:

This represents a repository of some kind, which will throw an error if the requested entity could not be found, unless one passes false as the second argument in which case undefined will be returned instead. This sort of thing is not too uncommon and you've probably seen something like it before.

While there doesn't appear to be anything particularly wrong with it at first glance, the fact that a missing argument is interpreted as true is problematic for several reasons. Let's explore why.

Semantic mismatch

First of all, there is a much closer semantic relationship between the concepts of "not present" and "not active" (false) than between "not present" and "active"/"not inactive" (true). That is to say, it is more surprising to find out that leaving an option out turned on a behavior rather than turning it off.

But, I hear the audience ask, what if that on state is in fact the desired default behavior, and the repository should fail loudly unless explicitly silenced? Simple – just invert the condition and rename the argument to match:

The provided functionality is the same as before, but now the option in question equates "not specified" with "not in use" instead. This would be the equivalent of the following overloads in Java, which does not have default arguments:

In the above example the default value of the flag argument cannot be determined from the signatures alone, so if the first overload were to call the second with allowMissing set to true the original caller would have no way of knowing that this is indeed the case. As such, equating "not provided" with false is the safer bet.

While this difference may still seem mostly like a matter of taste, let's look at some more cases where it takes on greater significance.

Default propagation

First of all, default-true flags risk breaking parameter propagation. Consider this TypeScript rendition of the original example, which uses an options object (more on that below) with optional properties in a dispatcher call:

What happened here? Well, the dispatcher tries to pass the value of options.throwOnError through to the repository function, but in the second call the options object has no property of that name, which resolves to undefined at runtime. Then, when the JS engine executes the dispatched function and finds undefined in the slot reserved for the throwOnError argument, it assumes that the argument is missing (which is not an error: this is what undefined means in that context) and sets it to the default value, which is true.

If the dispatcher caller only has access to the DispatcherOptions interface, they would have no way of knowing that leaving out the optional throwOnError flag will end up turning on the corresponding behavior in the repository. Surprise!

Framework templates

The propagation problem is especially noticeable in frontend framework templates. Both React and Vue (but not Angular, which requires property binding in all cases) follow the HTML5 specification with regard to boolean attributes:

  • <button id="btn" disabled>Click me</button>: document.getElementById("btn").disabled === true
  • <button id="btn">Click me</button>: document.getElementById("btn").disabled === false

In other words, leaving a boolean attribute out is the same as binding it to false, and including it with no bound value is the same as binding it to true. With this in mind, consider the following React component using the popular Ant Design UI library:

This looks simple enough: a wrapper component with some default props that renders a Card UI component. In this example, the card should be rendered without borders, right? Wrong! The reason is that the Ant Design developers have chosen to set the default value of the bordered prop to true, which in turn means that when bordered={props.bordered} resolves to undefined, the Card component sees that as true and renders the border anyway. (This would also be the case if the props were spread, which would be a reasonable code simplification to make here.)

The only way to get around this behavior is to coerce the offending prop to an actual boolean value, e.g. bordered={!!props.bordered}. It is also inconsistent with the Card component's other boolean props, which do default to false and therefore require no such special treatment. That's a bad design decision, plain and simple, and unfortunately this sort of thing is found all over that particular library.

In the Vue ecosystem, there's an ESLint rule for preventing precisely this sort of thing, which you really should be using.

Better left unsaid

We've only talked about flags so far, so let's broaden our view and consider other kinds of default values as well.

Suppose you have a function which takes some sort of required argument, and two callbacks (one for the success case and one for errors; old-style Node APIs are full of this pattern, for example). Then after a while you realize that you need an extra parameter in a few cases, so you add it at the end with a default value for all the other cases. Then you decide to make the error callback optional as well, letting an exception bubble up instead if it's not provided, and finally you add a flag parameter too (defaulting to false, of course). That evolution of the function signature may look something like this:

All well and good, but now we have a problem: What if you want to hit the default case for the first two optional parameters, and only set the flag? With the final signature above, you can't – or, more precisely, you have to explicitly provide the other defaults yourself:

While your IDE should be able to help you out here, you've now created a leaky abstraction, forcing all callers to know about things only some of them are expected to deal with. In the case of primitives like in this example it's not too bad, but it could just as well be something like enums (actual TypeScript ones or POJO stand-ins) which probably reside in some other file, forcing a superfluous import and complicating the dependency graph for no purposeful reason.

A better way to deal with this kind of situation is to use an options object passed after all required arguments, with default values for any properties not included. This can be handled in several ways, such as these:

Now the caller only has to know about the option(s) they actually use, leaving any and all others to be handled as defaults internally by the called function. It also makes the function future proof, as an arbitrary number of additional options can be added to the options object down the line without breaking existing call sites. This is also the route taken by many web APIs, in some cases superceding previous positional signatures.

However, beware of setting the default values directly in the signature here, as these will only apply when no options object is provided at all:

If you work in a language that supports keyword arguments, however, the situation is much simpler, since any unknown/unwanted parameters can then simply be omitted in the call:

Although if the argument list grows large, or is expected to at some point, it can be a good idea to combine them into an options object in these languages as well, which is also fairly common.

A matter of time

Different languages exhibit different behavior with regard to when a default value is actually assigned. Take this example in JavaScript, with a simple delay function to simulate pauses:

While the actual function doesn't do anything particularly useful or even reasonable here, the important thing to note is what happens with the default arguments. As we can see, both the array and the Date object are instantiated afresh every time the function is called, so in the JS case a default specification like this is just that: an instruction for what the engine should do whenever an incomplete call is dispatched.

In Python, however, it's a different story:

Here the default values are assigned only once, namely when the function is first defined, and then reused for every call. As lists are mutable, this means that operations on the default list instance will persist for all subsequent calls, since it will be the same instance every time. Similarly, the timestamp value never updates since it was only calculated at definition time.

To get around this problem, a more explicit approach needs to be taken, which will also hide the eventual default values from the signature:

In C# only values which can be determined at compile time are permitted as defaults, which means that the equivalent of the second Python example above is the only way to go there. If one really wants to, however, one can achieve the behavior in the first example, but this is fully opt-in and requires the use of the ref keyword both in the signature and at the call site:

Conclusion

This whole topic can be summed up in one simple idea: the principle of least surprise. To wit, implement your defaults in such a way that users can make reasonable assumptions about what your code does and how to use it, without having to delve into the internals of that code to find out. And, for the love of [insert idol here], be consistent and follow established specifications and conventions.

If that means that you have to jump through a few extra hoops in your code, then that price is yours to pay. Don't transfer it to your users and/or colleagues.