Programming: Optionality and Defaults

Ambiguity is a complexity

Jérôme Beau
10 min readJun 25, 2020
Two direction panels: one saying “Optional”, pointing left, the other saying “Mandatory”, pointing right.
Do the Right thing.

Suppose you are signing a contract with optional clauses. Would you care about those? Most of us would probably even bother reading them: if it’s optional, we don’t feel compelled to comply with it.

Prototype in disguise

Actually optionality is about assigning values by default, i.e. when no explicit ones are provided. For instance, this function implies that its two last parameters are optional:

formatPeople (people, withLast = true, withFirst?)

It is the same as:

formatPeople (people, withLast = true, withFirst = undefined)

Of course nothing is really optional by nature as the function needs all the values. It is just optional to provide some arguments, because those have default values. So this is just prototyping: creating a set of values by merging a prototype ({withLast: true, withFirst: undefined}) with the provided ones.

Now if we look at the possible calls of this function, we are able to call:

formatPeople (people1, true, true)
formatPeople (people1, true)
formatPeople (people1)

Another way of implementing it is like below:

defaultOptions = {   // Prototype
withFirst: undefined,
withLast: undefined
}


formatPeople (people, options) {
options = {...defaultOptions, options}
const first = options.withFirst ? people.firstName : ''
const last = options.withLast ? people.lastName : ''
formatted = `${first}${first && last ? ' ' : ''}${last}`
return formatted
}
formatPeople (people1, {firstName: true})

Unspoken magic values

Here a number of problems appear:

  • the caller might not know which value is the default (here the optional true could be any type, including un-guessable complex types encapsulated or inlined in the code) and so not understand what that value implies (you can see it every time a newbie developer tries to understand why some HTML elements react differently to the same CSS — it’s because of the different display defaults of each of them). Actually the caller might not know the very existence of some option, as not constrained to provide it. In the example above, the caller might not know that {gender: true} can be specified or even that lastName can be unset.
  • contract becomes unclear as providing a value unset (unspecified key or key but empty value) can be interpreted in different ways: set the value to empty or use the default value. In the example above, specifying {firstName: true} could mean both concatenating both first and (default) lastName, or just display the firstName. Similarly, providing a subset of values (i.e. a complementary subset of unset values) might be interpreted either as an override of all default values or just the ones from the subset.
  • you assume what should be the default, whereas it can depend on some context such as the use case. Maybe by making one client usage easier (by avoiding to provide a couple of arguments) you’re making another client usage harder. The truth is that you cannot decide and shouldn’t. Simplifying calls depending on the use case is a responsibility of the caller layer, not you.
  • you may mis-refactor: suppose you want to add a parameter to a call that has an optional one: if of the same type, you additional parameter may be understood as the optional one.

Here is a better (more explicit, clearer and finally simpler) version:

formatPeople (people, options) {
const first = options.firstName ? people.firstName : ''
const last = options.lastName ? people.lastName : ''
return `${first}${first && last ? ' ' : ''}${last}`
}
formatPeople (people1, {firstName: false, lastName: true})

A not-so-free construction

Another typical place when default values are used is constructors, as you want to ease calling them when it’s about initializing some “default” state.

class Document {

constructor(
private title: string,
private id = newId(),
private children = [],
private manager = new DocumentManager()
) {
}
}

This may be quite handy for a number of use case, but this is quite dangerous as allowing:

newDoc = new Document("Some title")

may let other developers:

  • overlook that other parameters may be required in their use case ;
  • don’t realize the cost of instantiating a DocumentManager each time.

But I don’t want to provide all parameters all the time

would you say. No you shouldn’t. Instead, you should keep your constructor strictly requiring all its state, then embed your specific construction inside a factory, that will make the default instantiation more explicit:

customDoc = docFactory.create("Some title", "12", [], sharedManager)
standaloneDoc = docFactory.createStandalone("Some title")

With such as signature, other developer get a hint that the creation is not a regular one, but to instantiate a specific flavor of documents (carrying their own manager). It would also be an opportunity to properly document the createStandalone() API (what it does differently, why it is needed, etc.).

Accepts everything, possibly do nothing

A typical implementation of optionality is a function that will perform only if a parameter has a specific value (a “valid” / supported value, which most of the time is anything but something falsy such as null or undefined):

function f (param?) {
if (param) { // Or if (!param) return
// All code
}
}

This is often motivated by laziness to avoid checking the value of a parameter from the outside, at each call.

However, this can lead to a bunch of ambiguities:

f(x)
y = null
f(y) // How do I know if it won't do anything?
f() // Should not be allowed, but it is

“But checking inside is more robust”, some would say. For sure, that makes sure the processing will always occur on valid values only. But this also makes sure that you will check for nothing (in cases where you know the value is valid) most of the time (assuming valid values are most common that invalid ones) and that no error will be raised in case of invalid values. That’s a silent failure implementation that may lead to painful bug hunting. Should you do that, at least throw an error (or at worse log it) in the invalid case.

All of this share the same drawback: you need to see the implementation to know how it will behave. Or read the docs, should you say, but checking the developer knows what (s)he is doing is not implemented by compilers yet.

To avoid all those drawbacks, just remove the optionality to make things clearer:

function f (param) {
// handle param
}
if (x) {
f (x)
}

And, of course, you may even avoid the if (x) check if your code flow (and so your compiler) knows that it cannot be the case.

Note that this can even get worse if the check can completely invalidates the building and convey of other parameters:

function f (param1, param2?) {
if (param2) {
// handle params
}
}
x = buildX() // costly
y = undefined
f(x, y) // You built x for nothing

Signing two contracts at a time

Even now and then, I stumble on this type of code:

function f (param1, param2?) {
handleParam1(param1) // Could be at function end as well
if (param2) {
handleParam2(param2)
}
}

This has the following drawbacks for the function:

  • the contract is more obscure. Notably, as for any function with optional parameter, this allows calling f (p1) without even knowing that a p2 was allowed and, more dangerously, without knowing what not having p2 implies.
  • the implementation is more complex because of the case management (you can imagine more complex handling and more diverse cases values).

To refactor this, we have to identify that this is actually an attempt to implement two use cases in one function. The author probably found it “simple” as a “if” patch seemed easy to write, but the result is something that is not easy to read or even use. Always be wary about adding “ifs”.

So let’s write the two functions instead:

function f1 (param1) {
handleParam1(param1)
}
function f2 (param1, param2) {
f1(param1)
handleParam2(param2)
}

Et voilà! Two clear contracts so you know which one suits your need and (provided you don’t evaluate simplicity through the number of lines), the code is now simpler:

  • no more optionality
  • no more case management

Optionality in OOP

Object-Oriented Programming benefits from optionality capabilities of the underlying language, but there are some specific use cases of optionality in OOP.

Attributes are not supposed to be optional

It is quite common to see optional attributes in object classes:

class Document {
constructor (
readonly title: string,
protected deletionDate?: Date
) {}
}
doc = new Document("New doc")

However an object is supposed to maintain a complete state so all its attributes are required. As a result, all of them are expected to be set in constructor, directly (through parameters) or indirectly (in code) and some linters will require you to do so.

The confusion here is (again) about optionality versus having undefined (or null) value, which is still required:

class Document {
constructor (
readonly title: string,
protected deletionDate: Date | undefined
) {}
}
doc = new Document("New doc", undefined)

Should you want to avoid providing undefined to fresh new documents, just implement that syntaxic sugar you expect:

function newDocument (title: string) {
return new Document (title, undefined)
}
doc = newDocument("New doc")

Serialization mapping

Another case is mapping some non-object data structure with an object model that involves inheritance. For instance you may want your objects data to persist in a non object database (such as a relational table) or to fetch/send them over the wire in some format (JSON or other).

The challenge here is to encode type variations: not only the type of the object, but also the attributes that are specific to such a type. For instance, the following object model:

class Document {
title: string
}
class Biography extends Document {
people: string
}
class Invoice extends Document {
companyId: number
}

could be encoded like this:

type JsonDocument = {
type: "bio" | "invoice"
title: string
people?: string
company_id?: number
}

Using such a “single table” mapping requires that you allow people to be undefined if the type is "invoice" or company_id to be undefined if the type is "bio".

However the exclusive nature of those options is not defined here, so that a JsonDocument can hold an inconsistent state such as a "invoice" with no company_id (as it is optional) or even with a people value:

parse (jsonDoc: JsonDocument): Document {
let doc: Document
switch (jsonDoc.type) {
case "bio":
const people = jsonDoc.people
if (!people) { // Won't compile without that check
throw Error("Bio's people field expected")
}
doc = new Biography (people)
break
case "invoice":
const companyId = jsonDoc.company_id
if (!companyId) { // Won't compile without that check
throw Error("Invoice's company_id field expected")
}
doc = new Invoice (companyId)
break
default:
throw Error("Unsupported doc type: " + jsonDoc.type)
}
return doc
}

How to improve this? We can define different types that clearly define the expectations of each case:

interface JsonDocument {
type: "bio" | "invoice"
title: string
}
interface JsonBio extends JsonDocument {
people: string
}
interface JsonInvoice extends JsonDocument {
company_id: number
}

Then enforce the type depending on what type you read:

parse (jsonDoc: JsonDocument): Document {
let doc: Document
switch (jsonDoc.type) {
case "bio":
const jsonBio = jsonDoc as JsonBio
doc = new Biography (jsonBio.people)
break
case "invoice":
const jsonInvoice = jsonDoc as JsonInvoice
doc = new Invoice (jsonInvoice.company_id)
break
default:
throw Error("Unsupported doc type: " + jsonDoc.type)
}
return doc
}

No more optionality here, you only have to handle well-defined cases.

Refactoring failures

Another drawback of using optional parameters is that it prevents IDEs to perform a number of refactorings, because they can’t guess what you intend to do. Consider for instance the following function:

function f (id, name?) {
// ...
}
f (1, "jerome")

Now suppose that you want to add another parameter to this function. Modern IDEs will take care of updating the existing calls for you with a default value for that new parameter… unless the calls already fit with the new signature, which will be the case by confusing the new parameter with the optional one:

function f (id, address, name?) {
// ...
}
f (1, "jerome") <-- "jerome" fits, but is not the address!

Another usual limitation is that you cannot add non-optional parameters after optional ones, like below:

function f (id, address, name?, mandatory) {  // Error
// ...
}
f (1, "paris", "jerome")

In such a case the compiler will report an error as it won’t be able to assign the value “jerome” because it could fit either name or mandatory (assuming name is not provided).

A more clear and refactorable signature would then be:

function f (id, address, name | undefined, mandatory) {
// ...
}
f (1, "paris", "jerome", "mandatory)
f (1, "paris", undefined, "mandatory)

This would then allow you to easily refactor the arguments order of the function, without loosing the calls arguments matching:

function f (id, address, mandatory, name | undefined) {
// ...
}
f (1, "paris", "mandatory, "jerome")
f (1, "paris", "mandatory)

So how to avoid it?

The solution seems pretty straightforward, but could be broke down in two steps.

Remove optional qualifiers

Take this example of an error handler that can be set:

class App {
private _errorHandler?: ErrorHandler = undefined
set errorHandler(newHandler: ErrorHandler) {
this._errorHandler = newHandler
}
handleError(error) {
if (!this._errorHandler) {
throw error
}
this._errorHandler.handle(error)
}
}

As you can see, since the field can be defined or not, you have to handle both cases. As you have understood, the problem is less to implement all cases — you’ll always have to — than to implement them at the same place.

So have a cleaner, more readable and flexible implementation, all you need is to implement your “undefined” as one of the possible cases. That way, it will be always defined:

class DefaultErrorHandler implements ErrorHandler {
handle(error) {
throw error
}
}
class App { constructor(private _errorHandler: ErrorHandler
= new DefaultErrorHandler()) {}
set errorHandler(newHandler: ErrorHandler) {
this._errorHandler = newHandler
}
handleError(error) {
this._errorHandler.handle(error)
}
}

Avoid defaults

Now what if you want to avoid default values as well? Just remove the default value and, instead of:

new App()

Just do:

new App(new DefaultErrorHandler())

Is it that hard? Now caller becomes far more aware that there is an error handler, and what its implementation will do, until a new one is set.

What about functions?

The same rule can be applied on function arguments like below:

function f (param?: number) {}f(12)         // works
f(undefined) // works too
f() // works as well

Just mandate providing values, including undefined:

function (param: string | undefined) {}f(12)         // works
f(undefined) // works too
f() // not allowed

Conclusion

Optionality is a shorthand for default values which, while claiming to simplify things, are often actually complexifying them:

  • for the caller by hiding parts of contracts and so making them unclear.
  • for the developer by requiring to implement different contracts in a single place (thus leading to more complex code that is both less-readable and harder to test). It seems easier to use optionality at first but, as code get more and more complex, leads to code that is harder to maintain. Code simplicity is a local virtue in software engineering. It is not related to a global number of lines of even global number of functions, but rather to the clarity and unicity of contracts.

They can be replaced by multiple contracts that will be clearer and easier to implement and optimize.

--

--