Written for a proposal I gave to my coworkers at Monzo.
When building an API endpoint, there is a translation that occurs between Go code and client code. For that to happen, we marshal the go structs into a JSON representation, which is then unmarshalled into the client types.
Due to our setup of using omitempty
on non-nullable types, unexpected behaviour happens.
This is best shown with a bool
type, as it's the simplest primitive type.
(see below for the disclaimer about strings)
TLDR:
An omitempty
should only be used with a pointer type to allow three distinct values
- true
- false
- undefined
The Problem
When an omitempty
attribute is added to a type that has a valid (useful) zero value, then it makes it impossible for clients to know the intent.
Take for example the boolean flag ShowCurrency
on the ListComponentItemMoneyContent
type
This means that when serialising, there are only two possible values:
true
or undefined
Outputs:
This is a problem because client engineers believe they can receive 3 possible values: true
, false
, or undefined, whereas in reality there can only be 2 values.
And the go expectation is that undefined is really false
because I’ve given the value of false
either explicitly or implicitly through the zero-value instantiation.
But clients may falsely assume that undefined maps to true
.
So this is a problem.
Client’s assume defaults
When client engineers can receive 3 possible values, but backend engineers can only create 2 possible values, then clients must make assumptions on what to do with that 3rd option.
For the above case, a backend engineer might assume, “well if you receive undefined, then you should default to false”, but that is not always a good assumption.
This also may hide problems in the data structure, for example if you make a typo in the backend, like show_curency
,
the interface will be broken, but we might not notice because the frontend client will instead assume that the key is missing and therefore it’s false
.
This is a good argument for using null
instead of undefined to represent the empty state.
ShowCurrency defaults to true, which means that as it is built into the app today, it’s impossible to hide currency. Either the backend supplies true, or the client receives undefined and falls back to true. (false
is impossible to achieve)
ShowSign defaults to false, which is maybe more expected, but still implicit.
Check out this Swift code that we had in our iOS app code.
Also, what if my type really had three options? What if true
, false
, or undefined are all valid?
The problem arises because clients don’t see any difference between this option and the previous. In either case they see a bool
that may be undefined. So they have 3 options whether or not it was intended to be so.
And if clients see three possible options, they will handle true
, false
, and then have to decide what to do when it is undefined. And as we showed here, it’s not always the same.
This problem arises because of the Go annotation.
How can we fix this?
To fix the problem, we can simply be explicit about whether or not a type can actually be undefined.
This means that we never use omitempty
with a non-pointer type (see disclaimer)
And if we want to represent possible undefined values, use a pointer!
So for our original example, we can change the type to be the following:
For this struct, the zero-value is now no longer a false
, it is nil
. And thus, backend engineers can actually choose between three possible values.
Outputs:
Now backend engineers can actually specify between 3 possible values, and client engineers can properly see all three options.
Monzo Specific problems
Most dynamic views are not created directly in an API service, they come through one extra translation layer (proto).
As such, this is extra complicated.
While our API could easily be extended to add *bool pointers everywhere, our proto does not currently support optional properties.
This means that while a dynamic view built directly into the API could use an optional bool, all bools that come through RPC will be coerced to be a real value.
Now you might think that this might actually be okay. Our app should not even need to worry about optional bools. Every time a bool isn’t explicitly initialized it’s false.
So, then we might think to fix this we could just remove all the omitempty
, and then our app will always get the expected value.
The problem with this is that the app (iOS) will map “undefined” to true in some cases. So while the backend returns a false
, it is mapped to undefined, and then the frontend will use a value of true
.
If we just change the show_currency
to not have omitempty
, then across the app, most dynamic views won’t show the currency, which is unexpected. Since for that particular toggle, returning true or false has zero impact on what happens, most developers won’t have included that boolean value as true. As such, removing omitempty
will change developer’s previous expectations, even though it is more correct.
To fix this problem, our proto needs to change to allow bool pointers. We can do this by introducing a wrapped boolean
With a couple of go helpers in the same package
Then our proto can be updated to have the following for ShowCurrency
And now the callers, can decide if they want to pass in true
, false
, or nil
Then when we want to serialise it, we can simply marshal using our helper function
Now we can properly pass true
, false
, and undefined from a service all the way to the client.
Discussion about other types
This discussion has focused primarily on bools, however it’s valuable to look at the other types.
The key insight about whether or not you should do what is described in this document is whether or not the zero value of a type is useful on its own. Obviously that means that it depends on the what you are trying to achieve. Context matters! This is not a catch-all.
For a bool, the zero value (false
) is meaningful on its own, and doesn’t mean "empty" on its own. This is true of the int
types, as well as the float
types. 0
doesn’t necessarily mean the absence of a number.
Where it gets particularly tricky are string
types. The zero value of a string is ""
, which does imply emptiness.
For strings, the general assumption would be that an undefined or null
string is equal to ""
. This is normally true. There’s not a meaningful difference between a blank string and a null
string.
Think, how would a user even enter a blank string as opposed to null
; if user’s can’t distinguish, then it’s probably semantically identical.
For most cases, using omitempty
for a string is okay.
- My API style opinion
While using
omitempty
is okay for strings. I generally would prefer to not useomitempty
. I would rather have a single empty state, rather than create multiple options that clients have to deal with. Many subtle bugs have been introduced this way. I would take the backend code and mapnil
s to""
It is for this same reason that I prefer to return empty arrays rather than undefined ornull
. While we’re at it, I also generally don’t like to use a mixture of bothnull
and undefined to represent “empty” values. I generally prefernull
This also applies to objects. If you have a struct in your response with omitempty
it will never be omitted.
It needs to be a pointer too if you want it to actually become undefined.
Outputs: