When I was working on my small side-project library, I needed to represent a missing value. In the past, I'd used the nullable approach in simple settings and Option (aka Maybe) when I wanted more control.
In this case, neither felt correct so I came up with a different approach I'd like to present.
Why Nullable was not enough
Nullable means that when there is a value it is a string, a number, or an object. When there is no value, we use either null
or undefined
.
Tip: if you work with nullable types in TypeScript, make sure you turn on the strictNullChecks
This is often fine.
There are, in general, two cases when it's not:
The value can be
null
orundefined
. In the end, these are both valid JavaScript primitives and people can use them in many ways.You want to add some advanced logic. Writing
x == null
everywhere gets cumbersome.
In my case I was handling an output of a Promise, that can return
anything. And I could foresee that both of the ‘missing’ will be eventually returned.
In general, the problem 1 and 2 have the same solution: use a library that implements the Option type.
Why Option was too much
Option (sometimes called Maybe) type has two possibilities: either there is no value (None
on Nothing
) or there is a value (Some
or Just
).
In JavaScript/TypeScript this means introducing a new structure that wraps the value. Most commonly an object with a property tag
that defines what possibility it is.
This is how you could quickly implement Option in TypeScript:
type Option<T> = { tag: 'none' } | { tag: 'some', value: T }
Usually, you would use a library that defines the type and a bunch of useful utils alongside. Here is an intro to Option in my favourite fp-ts library.
The library I was building was small, had zero dependencies, and there was no need for using any Option utility. Therefore, bringing in an Option library would be overkill.
robinpokorny / promise-throttle-all
🤏 Promise.all with limited concurrency
For a while I was thinking about inlining the Option, that is coding it from scratch. For my use case that would be just a few lines. It would complicate the logic of the library a bit, though.
Then, I had a better idea!
Symbol as the new null
Coming back to Nullable, the unsolvable problem is that null
(or undefined
) is global. It is one value equal to itself. It is the same for everybody.
If you return null
and I return null
, later, it is not possible to find out where the null
comes from.
In other words, there is ever only one instance. To solve it, we need to have a new instance of null
.
Sure, we could use an empty object. In JavaScript each object is a new instance that is not equal to any other object.
But hey, in ES6 we got a new primitive that does exactly that: Symbol. (Read some introduction to Symbols)
What I did was a new constant that represented a missing value, which was a symbol:
const None = Symbol(`None`)
Let's look at the benefits:
- It is a simple value, no wrapper needed
- Anything else is treated as data
- It's a private None, the symbol cannot be recreated elsewhere
- It has no meaning outside our code
- The label makes debugging easier
That is great! Especially the first point allows using None as null
. See some example use:
const isNone = (value: unknown) => x === None
const hasNone = (arr: Array<unknown>) =>
arr.some((x) => x === None)
const map = <T, S>(
fn: (x: T) => S,
value: T | typeof None
) => {
if (value === None) {
return None
} else {
return fn(value)
}
}
Symbols are almost nulls
There are some disadvantages, too.
First, which is IMO rare, is that the environment has to support ES6 Symbols. That means Node.js >=0.12 (not to be confused with v12).
Second, there are problems with (de)serialisation. Funnily, Symbols behave exactly like undefined
.
JSON.stringify({ x: Symbol(), y: undefined })
// -> "{}"
JSON.stringify([Symbol(), undefined])
// -> "[null,null]"
So, the information about the instance is, of course, lost. Yet, since it then behaves like undefined
—the native ‘missing value’)—makes it well suited for representing a custom ‘missing value’.
In contrast, Option is based on structure not instances. Any object with a property tag
set to none
is considered None. This allows for easier serialisation and deserialisation.
Summary
I'm rather happy with this pattern. It seems it's a safer alternative to null
in places where no advanced operations on the property are needed.
Maybe, I'd avoid it if this custom symbol should leak outside of a module or a library.
I especially like that with the variable name and the symbol label, I can communicate the domain meaning of the missing value. In my small library it represents that the promise is not settled:
const notSettled = Symbol(`not-settled`)
Potentially, there could be multiple missing values for different domain meanings.
Let me know what you think of this use? Is it a good replacement for
null
? Should everybody always use an Option?
Note: Symbols are not always easy to use, watch my talk Symbols complicated it all.
Top comments (5)
One potential problem I can see from this approach is that js symbols convert to true. Which means it can confuse people when used like this:
Yeah, I see that point. However, with so many falsy values in JS, I would not use the same for
null
orundefined
either.Maybe
NaN
is a better choice? :DActually, I was caught off guard by empty strings a few times before. You have a good point.
Hey! I'm not sure if
undefined
is better thannull
, in most cases there is not much difference. The. benefit ofnull
or a Symbol is that it is more explicit. For me,undefined
is the value the interpreter,null
or Symbols are for users (this is not how it works, it's how I look at it).Anyway, nullable for me means either
undefined
ornull
and the biggest issue with it remains: there is only one (well, two :D) and you cannot control how others use it.Interesting point of view