Type-safe nullability without strictNullChecks
TypeScript's
strictNullChecks
changes the assignability rules of null
and undefined
. They will no longer
be assignable to any value.
Compare the behavior for the following snippet with strictNullChecks
enabled
(TS playground)
and disabled
(TS playground):
declare function foo(value: string): void;
foo(null);
When that compiler options is turned off, TypeScript is fine with passing null
(or undefined
) in any position, even if the type does not say that these
nullable values are allowed. This is rectified by enabling the option, which
produces a compilation error:
Argument of type 'null' is not assignable to parameter of type 'string'.
Turning on that option in a TypeScript project is either all or nothing. There is no easy way to enable it incrementally only for some part of the project (although you can look at loose-ts-check which I wrote which aims to allow this incremental approach). This makes code migrations hard.
What if we want to express nullability in a type-safe way while being
constrained to keep strictNullChecks
disabled?
fp-ts's Option to the rescue
One way to model nullability is with the fp-ts's
Option
type. It
represents nullable values with
the None
object:
export interface None {
readonly _tag: "None";
}
A defined value of type A
is represented with
the Some<A>
object:
export interface Some<A> {
readonly _tag: "Some";
readonly value: A;
}
They are combined in
a single Option<A>
type union:
export type Option<A> = None | Some<A>;
The rules are simple. If some value T
is nullable, it should be represented as
T
. Otherwise, if it is just the value T
, we assume it is non-nullable.
Example
Let's look at this example which prints a binary tree in order:
import { option } from "fp-ts";
import { pipe } from "fp-ts/function";
interface TreeNode<T> {
value: T;
left: option.Option<TreeNode<T>>;
right: option.Option<TreeNode<T>>;
}
function printTree(node: TreeNode<unknown>) {
pipe(node.left, option.map(printTree));
console.log(node.value);
pipe(node.right, option.map(printTree));
}
You can clearly see which values the code expects to be nullable, and which are
required. We need to unwrap the value from the left
and right
fields. We
cannot just use the value without checking if it is there. Let's compare that
with the code that uses undefined
to express nullability (with
strictNullChecks
still disabled):
interface TreeNode<T> {
value: T;
left: TreeNode<T> | undefined;
right: TreeNode<T> | undefined;
}
function printTree(node: TreeNode<unknown>) {
if (node.left) {
printTree(node.left);
}
console.log(node.value);
if (node.right) {
printTree(node.right);
}
}
If we forgot that left
and right
are nullable and used them without checking
if they are defined, we get a TypeError
for trying to access node.left
on an
undefined
node
:
function printTree(node: TreeNode<unknown>) {
printTree(node.left); // will throw a TypeError
console.log(node.value);
printTree(node.right);
}
Caveat
Using Option
does not prevent passing null
or undefined
as any argument.
The code will still compile without errors because strictNullChecks
are
disabled.
const treeNode: TreeNode<number> = {
value: 1234,
left: null, // should be option.none,
right: undefined, // should be option.none,
};
The use of Option
to express nullability is more of a hint to engineers. I
still believe it easier to use the Option
type to say which fields are
nullable rather than have to learn it by heart, read through the code again if I
want to find it out, or produce bugs by not paying attention to nullability.
Conclusion
fp-ts exposes an Option
type which represents nullable values in a type-safe
way that hints to other engineers which values can be missing. We must first
check if the value is inside an Option
to get access to it, which makes it
safe to use without having to remember whether a value is nullable. This is
particularly useful in TypeScript projects without strictNullChecks
enabled.