Vacuousness
I don't recommend trying to define or use a function which computes whether any value in the whole world is empty. What does it really mean to be "empty"? If I have let human = { name: 'bob', stomach: 'empty' }
, should isEmpty(human)
return true
? If I have let reg = new RegExp('');
, should isEmpty(reg)
return true
? What about isEmpty([ null, null, null, null ])
- this list only contains emptiness, so is the list itself empty? I want to put forward here some notes on "vacuousness" (an intentionally obscure word, to avoid pre-existing associations) in javascript - and I want to argue that "vacuousness" in javascript values should never be dealt with generically.
Truthiness/Falsiness
For deciding how to determine the "vacuousness" of values, we need to accomodate javascript's inbuilt, inherent sense of whether values are "truthy" or "falsy". Naturally, null
and undefined
are both "falsy". Less naturally, the number 0
(and no other number except NaN
) is also "falsy". Least naturally: ''
is falsy, but []
and {}
(and new Set()
, and new Map()
) are truthy - although they all seem equally vacuous!
Null vs Undefined
There is also some discussion concerning null
vs undefined
- do we really need both in order to express vacuousness in our programs? I personally avoid ever having undefined
appear in my code. I always use null
to signify "vacuousness". Again, though, we need to accomodate javascript's inherent sense of how null
and undefined
differ:
- Trying to access a non-existent property gives
undefined
- Omitting a parameter when calling a function results in that parameter receiving
undefined
:
let f = a => a;
console.log(f('hi'));
console.log(f());
- Parameters with default values receive the default only when given
undefined
, not null
:
let f = (v='hello') => v;
console.log(f(null));
console.log(f(undefined));
To me, null
is an explicit signifier of vacuousness; "something that could have been filled in was intentionally left blank".
Really undefined
is a necessary complication that allows some js features to exist, but in my opinion it should always be left behind the scenes; not interacted with directly. We can think of undefined
as, for example, javascript's mechanic for implementing default function arguments. If you refrain from supplying an argument to a function it will receive a value of undefined
instead. And a default value will be applied to a function argument if that argument was initially set to undefined
. In this case undefined
is the linchpin of default function arguments, but it stays in the background: we can achieve default argument functionality without ever referring to undefined
:
This is a bad implementation of default arguments as it interacts directly with undefined
:
let fnWithDefaults = arg => {
if (arg === undefined) arg = 'default';
...
};
This is a good implementation:
let fnWithDefaults = (arg='default') => { ... };
This is a bad way to accept the default argument:
fnWithDefaults(undefined);
Simply do this instead:
fnWithDefaults();
By the way: do you have a function with multiple arguments, and you want to provide some arguments while accepting defaults for others?
E.g.:
let fnWithDefaults = (a=1, b=2, c=3, d=4) => console.log(a, b, c, d);
If you want to provide values for a
and d
and accepts defaults for the others what to do? This seems wrong:
fnWithDefaults(10, undefined, undefined, 40);
The answer is: refactor fnWithDefaults
to accept a single object:
let fnWithDefaults = ({ a=1, b=2, c=3, d=4 }={}) => console.log(a, b, c, d);
fnWithDefaults({ a: 10, d: 40 }); // Now this looks really nice! (And never talks about "undefined")
Non-generic Vacuousness
I believe that vacuousness should never be dealt with in a generic fashion. We should instead always have the rigour to get more information about our data before determining if it is vacuous - I mainly do this by checking what type of data I'm dealing with:
let isType = (value, Cls) => {
// Intentional use of loose comparison operator detects `null`
// and `undefined`, and nothing else!
return value != null && Object.getPrototypeOf(value).constructor === Cls;
};
Note that this function ignores inheritance - it expects value
to be a direct instance of Cls
, and not an instance of a subclass of Cls
. I avoid instanceof
for two main reasons:
([] instanceof Object) === true
("An Array is an Object")
('' instanceof String) === false
("A String is not a String")
Note that Object.getPrototypeOf
is used to avoid an (obscure) edge-case such as let v = { constructor: String };
The isType
function still returns correctly for isType(v, String)
(false), and isType(v, Object)
(true).
Overall, I recommend using this isType
function along with these tips:
- Minimize the amount of code processing values of unknown type. E.g., for
let v = JSON.parse(someRawValue);
, our v
variable is now of unknown type. As early as possible, we should limit our possibilities. The best way to do this can be by requiring a particular type: e.g. if (!isType(v, Array)) throw new Error('Expected Array');
- this is a really quick and expressive way to remove the generic nature of v
, and ensure it's always an Array
. Sometimes, though, we need to allow v
to be of multiple types. In those cases, we should create blocks of code where v
is no longer generic, as early as possible:
if (isType(v, String)) {
/* v isn't generic in this block - It's a String! */
} else if (isType(v, Number)) {
/* v isn't generic in this block - It's a Number! */
} else if (isType(v, Array)) {
/* v isn't generic in this block - it's an Array! */
} else {
throw new Error('Expected String, Number, or Array');
}
- Always use "whitelists" for validation. If you require a value to be, e.g., a String, Number, or Array, check for those 3 "white" possibilities, and throw an Error if none of the 3 are satisfied. We should be able to see that checking for "black" possibilities isn't very useful: Say we write
if (v === null) throw new Error('Null value rejected');
- this is great for ensuring that null
values don't make it through, but if a value does make it through, we still know hardly anything about it. A value v
which passes this null-check is still VERY generic - it's anything but null
! Blacklists hardly dispell generic-ness.
- Unless a value is
null
, never consider "a vacuous value". Instead, consider "an X which is vacuous". Essentially, never consider doing anything like if (isEmpty(val)) { /* ... */ }
- no matter how that isEmpty
function is implemented (I don't want to know...), it isn't meaningful! And it's way too generic! Vacuousness should only be calculated with knowledge of val
's type. Vacuousness-checks should look like this:
"A string, with no chars":
if (isType(val, String) && val.length === 0) ...
"An Object, with 0 props": if (isType(val, Object) && Object.entries(val).length === 0) ...
"A number, equal or less than zero": if (isType(val, Number) && val <= 0) ...
"An Array, with no items": if (isType(val, Array) && val.length === 0) ...
The only exception is when null
is used to signify certain functionality. In this case it's meaningful to say: "A vacuous value": if (val === null) ...