Best Practices for Using Maybes and Creating Default Values

Introduction

You’ve learned that using Maybe s allows you to get rid of null pointer exceptions (i.e. “undefined is not a function”). However, now your application fails and gives no indication as to why. Errors would have left a stack trace that could have provided a hint as to where the problem originated, but, clearly here, you don't have that to rely on. What should you be doing instead?

Null Pointers in Python vs. Ruby, Lua, and JavaScript

Let’s define what we mean by null pointers and how you usually encounter them. Most null pointers you’ll run into are from either accessing a property of an object to show on the screen or calling a method on an object.

Python Is Strict

Accessing objects (dictionaries in Python) is a very strict process. If the dictionary exists but the name doesn’t or you spell it wrong, you’ll get an exception:

cow = { "firstName" : "Jesse" }
print(cow["fistName"])
KeyError: 'firstNam'

Ruby, Lua, and JavaScript Are Not Strict

Ruby, Lua, and JavaScript, however, will return a nil or undefined if you access a property that doesn’t exist on the hash/table/object:

cow = { firstName: "Jesse" }
console.log(cow["fistName"]) // undefined

Benefits of Getting Null/Nil vs. Exceptions

All three languages will return their version of “nothing”. For some applications, this works out quite well:

  • UI applications when displaying data.
  • APIs that orchestrate many other APIs or solely exist to get around CORS.
  • Any code dealing with NoSQL-like data.

For UIs, you typically do not control your data; you’re often loading it from some API. Even if you write this yourself, names can get out of sync with the UI if you change things.

For APIs, you’ll often write orchestration APIs for UIs to access one or many APIs to provide a simpler API for your UI. Using yours, you’ll make a request with efficient, formatted data instead of multiple requests with error handling and data formatting done on the client-side. Other times, you want to use an API, but it has no support for CORS. The only way your website can access it is if you build your own API to call since APIs are not prevented from accessing data out of their domains like UI applications are.

For NoSQL-like data, you’ll often have many objects with the same or similar type fields, but either the data is low quality, inconsistent, or both. Often, this is user-entered and thus there is no guarantee that a record has “firstName” (for example) as a property.

Careful for What You Wish for

However, this has downstream effects. Sometimes code will be expecting a String or a Number , and instead, it will receive  undefined and break. The following exception your program throws could then indicate that the error occurred in the wrong place; the error started upstream, but the stack trace might not show that. The reverse could also happen without a type system where a String is returned, but the downstream is expecting an Array and queries length, producing odd result.

While the benefits of being flexible are good, using a Maybe to force a developer to handle cases where undefined is returned instead is a better practice.

Maybes to the Rescue

Maybe s provide you with two ways to deal with null data. You can either get a default value:

// Lodash/fp's getOr
getOr('unknown name', 'fistName', cow) // unknown name

Or, you can match by using a match syntax provided by a library or a switch statement using TypeScript in strict-mode — which ensures you’ve handled all possible values:

// Folktale's Maybe
cowsFirstNameMaybe.matchWith({
  Just: ({ value }) => console.log("First name is:", value),
  Nothing: () => console.log("unknown name")
})

This, in theory, is one of the keys to ensuring that you don’t get null pointer exception, as you ensure you get a type and force your code to handle what happens if it gets a null value.

Downstream Still Suffers

However, Maybe s can still cause issues downstream just like undefined can. They do this via default values. In the getOr example above, we provided “unknown name”. If we get nothing back, we just default to “unknown name” and handle the problem later. 

Unfortunately, this can hide bugs. For non-FP codebases, a downstream function/class method will get null data and break. For FP codebases, you could get default data, which was never intended. This is what we’ll focus on below.

Examples of Default Value Causing UI Drama

Let’s define what we mean by default value. There is the imperative version where function arguments have default values for arguments if the user doesn’t supply a value and a Maybe , which often comes with a default value through getOr in Lodash, getOrElse in Folktale, or withDefault in Elm.

Default Values for Function Parameters

Default values are used by developers when methods have a common value they use internally. They’ll expose the parameter in the function but give a default value if the user doesn’t supply anything.

The date library moment does this. If you supply a date, it’ll format it:

moment('2019-02-14').format('MMM Do YY')
// Feb 14th 19

However, if you supply no date, the output will default to newDate() :

moment().format('MMM Do YY')
// Jul 14th 19

Think of the function definition something like this. If you don’t supply a maybeDate parameter, JavaScript will just default that parameter to now.

function moment(maybeDate=new Date()) {

Default Values for Maybes

While Maybe s are useful, things can get dangerous when you don’t know what the default values are. In Moment’s case, it’s very clear what no input means: now. Other times, however, it’s not clear at all. Let’s revisit our default value above:

getOr('unknown name', 'fistName', cow) // unknown name

What could possibly be the reason we set the default value, “unknown name”? Is it a passive aggressive way for the developer to let Product know the back-end data is bad? Is it a brown M&M for the developer to figure out later? The nice thing about strings is you have freedom to be very verbose in explaining why that string is there.

getOr('Bad Data - our data is user input without validation plus some of it is quite old being pulled from another database nightly so we cannot guarantee we will ever have first name', 'fistName', cow)

Oh… ok. Much more clear now. However, that clarity suddenly spurs ideas and problem solving. If you don’t get a name, the Designer can come up with a way to display that instead of “unknown name,” which could actually be the wrong thing to show a user. We do know, for a fact, that the downstream database never received a first name inputted by the user. It’s not our fault there is no first name; it’s the user’s.

Perhaps implementing a read-only UI element that lets the user know this would be a solid decision? The point here is that you are investing your team’s resources to solve these default values. You all are proactively attacking what would usually be a reaction to a null pointer.

Downstream Functions Not Properly Handling the Default Value

Strings for UI elements won’t often cause things to break per se, but other data types later down the line might.

const phone = getOr('no default phone number', 'address.phoneNumber[0]', person)
const formatted = formatPhoneNumber(phone)
// TypeError

The code above fails because formatPhoneNumber is not equipped to handle strings that aren’t phone numbers. Types in TypeScriptElm , or perhaps property tests using JSVerify could have found this earlier.

Default Values for Maybes Causing Bugs

Let’s take a larger example where even strong types and property tests won’t save us. We have an application for viewing many accounts. Notice the pagination buttons at the bottom.

We have 100 accounts and can view 10 at a time. We’ve written two functions to handle the pagination. Both have bugs. We can trigger the bug by going to page 11.

The first bug, allowing you to page beyond the total pages, is an easy fix. Below is the Elm code and equivalent JavaScript code:

nextPage currentPage totalPages =
  if currentPage < totalPages then
    currentPage + 1
  else
    currentPage
// JavaScript
const nextPage = (currentPage, totalPages) => {
  if(currentPage < totalPages) {
    return currentPage + 1
  } else {
    return currentPage
  }
}

We have 100 accounts chunked into an Array containing nine child Arrays (our “pages”). We’re using that currentPage as an Array index. Since Arrays in JavaScript are zero-based, we get into a situation where currentPage gets set to 10. Our Array only has 9 items. In JavaScript, that’s undefined :

accountPages[9] // [account91, account92, ...]
accountPages[10] // undefined

If you’re using Maybe s, then it’s Nothing :

accountPages[9] // Just [account91, account92, ...]
accountPages[10] // Nothing

Ok, that’s preventable. Just ensure currentPage can never be higher than the total pages. Instead, subtract one from totalPages :

if currentPage < totalPages - 1 then
if(currentPage < totalPages - 1) {

Great, that fixes the bug; you can’t click next beyond page 10. But, what about that second bug? How did you get a blank page? Our UI code, if it gets an empty Array, won’t render anything. Why did we get an empty Array? Here’s the offending, abbreviated Elm or JavaScript code:

getCurrentPage totalPages currentPage accounts =
  chunk totalPages accounts
  |> Array.get currentPage
  |> Maybe.withDefault []
const getCurrentPage = (totalPages, currentPage, accounts) => {
  const pages = chunk(totalPages, accounts)
  const currentPageMaybe = pages[currentPage]
  if(currentPageMaybe) {
      return currentPageMaybe
  }
  return []
}

Both provide an empty Array as a default value if you get undefined . It could be either bad data the index currentPage but in our case, we were out of bounds — trying to access index 10 in an Array that only has nine items.

This is where lazy thought, as to how a Nothing could happen results in issues downstream. This is also where types, even in JavaScript, which doesn’t have them but can be enhanced with libraries, really can help prevent these situations. I encourage you to watch Making Impossible States Impossible by Richard Feldman to get an idea of how to do this.

Conclusions

Really think about four things when you’re using Maybes and you’re returning a Nothing.

If it truly is something you cannot possibly control, it requires someone upstream to handle it. That is the perfect use case, and why Object property access and Array index access are the two places you this used most.

Second, have you thought enough about how the Nothing can occur? The example below is obvious:

const list = []
console.log(list[2]) // undefined

But what about this one?

const listFromServerWith100Items = await getData()
console.log(list[34]) // undefined

If accessing data is integral to how your application works, then you are probably better served being more thorough in your data parsing and surfacing errors when the data comes in incorrectly. Having a parse error clearly indicates where data is missing and is preferable to having an unexpected Nothing .

Third, be cognizant about your default values. If you’re not going to use a Result , which can provide more information about why something failed, then you should probably use a data type that comes embedded with information. Watch “Solving the Boolean Identity Crisis” by Jeremy Fairbank to get a sense of how primitive data types don’t really help us to understand what methods are doing and how creating custom types can help.

Specifically, instead of [] for our getCurrentPage functions above, use types to describe how you could even have empty pages. Perhaps, instead, you should return a Result Error that describes accessing a page that doesn’t exist or an EmptyPage type rather than an empty Array — leaving us to wonder if our parsing is broken, we have a default value somewhere, or we have some other problem.

Fourth, think about if the default values will have downstream effects. Even if you aren’t practicing Functional Programming, using default values means your function will assume something. This function will then be used in many other functions/class methods. Your function is part of a whole machine, and it’s better to be explicit when defining a default value you’re returning. This can be a verbose String explaining the problem,  a number that won’t negatively affect math (such as 0 instead of the common -1), or a custom type like DataDidntLoadFromServer .

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章