In programming languages with nullish types: null, nil, undefined, and so
on, programmers will usually reach for one of the nullish tokens when
representing something that can be optional or clearable. Something interesting
happens when you combine that form of optionality with another form of
optionality: fixed-length lists that represent slots to be filled in.
At work!
The ideas in this post started at work — a discussion, a bug, an insight — and made it all the way here.
Using nothing in place of something
Given an array of User items, it is possible to represent a variable number of
users, to do amazing things such as loop over them, render them, print them, etc.
interface User {
name: string;
}
const users: User[] = [{ name: "A"}, {name: "B"}, { name: "C" }];
An additional requirement may be that it should be possible to display empty,
to-be-filled-in users. A naïve way might be to represent the nothing/something
dichotomy as a union type where one member is User and the other is nullish;
undefined for example.
This is what I describe as using nothing in place of something. Let's
declare an instance in the list of users as the union of User and undefined:
const users: (User | undefined)[] = [ ... ];
This is probably fine in many cases.
Slotted list
Let's consider a slotted list. Imagine an array that has a fixed length: the starting lineup for a football team or the number of slots in a toaster. Let your imagination run free. You can have empty slots, and the slots are not filled in any particular order.
const slots: (User | undefined)[] = [
undefined,
undefined,
{ name: "A"},
undefined,
{ name: "B"},
And so on
];
A problem with this approach is that (User | undefined) can become contagious; a
programmer might ask "Why is it necessary to handle undefined everywhere?",
and "Should the array be passed as-is or could it be filtered to pass only
non-nullish values?".
You will need to pass references to (User | undefined)[] around. At some point
the distance between the code that centers around (User | undefined)[], which
motivates its existence, and other code — rendering, parsing, API calls
— will be large enough that the original meaning gets lost. It is not
clear from its type signature why it needs to be able to be undefined.
When an array like this is received and passed on, am I allowed to filter it? The originator knows that it's a slotted list, so it can't be filtered — lest we lose information — but not all clients know that.
What we needed is an approach that expresses something that is stronger than just nullishability.
Borrowing Option
I would use a type like this instead, modelled after Rust's Option type:
export const enum OptionKind {
Some = 1,
None,
}
export interface Some<T> {
kind: OptionKind.Some;
value: T;
}
export interface None {
kind: OptionKind.None;
value: undefined;
}
export type Option<T> = Some<T> | None;
A few trivial helper functions like some<T>(t: T) and none() are missing,
and are left as an exercise for the reader.
The virtues of Rust's Option type are well documented, and Option above is
very similar, but just to spell everything out
Nonecan be matched onNonecan be used in arrays instead ofundefined- It is clear that
Noneis a placeholder value
Now we can have this, which is better than before
const users: Option<User>[] = [
none(),
none(),
some({ name: "A"}),
none(),
some({ name: "B"}),
And so on
];
We can now decide that the undefined type variant in T | undefined means
"nothing", in other words that it hasn't been assigned a value yet. The None
variant of Option similarly means "something", or "placeholder".
The "nothing values" can keep meaning nothing. I'm not fond of JavaScript and
TypeScript having both undefined and null, but it is what it is. It might
have made sense when JavaScript was (apocryphally) created in 10 days, but I
don't have to like it.