Null was a billion-dollar mistake. Falsy was the second.
"I call it my billion-dollar mistake. It was the invention of the null reference in 1965."
— Tony Hoare, 2009
Tony Hoare gets to call it a billion-dollar mistake because he invented null and watched the industry pay for it for forty years. Most of us inherited that mistake and added our own contributions on top. JavaScript, in particular, made two design choices in the 1990s that we are still paying for in 2026 and will keep paying for in any new codebase that ships tomorrow.
The first is having null and undefined as two distinct values for the same thing.
The second is "falsy."
I built scrml — a single-file, full-stack reactive web language — partly because I got tired of paying. The intro post is over here; this post is about the absence-and-truthiness fix that scrml makes possible because it is its own language with its own rules. It's also a rant, because some design decisions deserve one.
Mistake 1: two values for "no value"
JavaScript decided in 1995 that there should be both null and undefined. They mean almost the same thing. They are not interchangeable. They behave subtly differently across operators, methods, JSON, equality, and type coercion.
typeof null // "object" ← yes, really
typeof undefined // "undefined"
null == undefined // true (loose equality treats them as the same)
null === undefined // false (strict equality does not)
JSON.stringify({a: null, b: undefined})
// '{"a":null}' ← undefined is silently dropped
Where does each one come from? You can't predict it. null shows up wherever a developer typed it. undefined shows up:
- when a variable is declared but not initialized (
let x;) - when an object property doesn't exist (
obj.missing) - when an array index is out of bounds (
arr[999]) - when a function returns nothing (
function f() {}) - when optional chaining short-circuits (
a?.b?.c) - when destructuring a missing key (
const { x } = {}) - when a function parameter is omitted (
f()wheref(x))
Some libraries normalize on null. Some normalize on undefined. Most do whatever the original author thought was right that day. You do not get to know in advance which one any given codepath will hand you.
So we write defensive code:
if (x !== null && x !== undefined) { ... }
Or the clever version:
if (x != null) { ... } // != catches both null and undefined
The clever version works because of loose equality. So now your "safe" check requires the operator your linter has spent five years trying to get rid of. Every codebase has both forms. Every code review is a small argument about which to use. None of this is solving a problem; it is mopping up a design choice from 1995.
Mistake 2: falsy
The second mistake is bigger. JavaScript decided that in any boolean context — if, while, &&, ||, the ternary — six values should evaluate as false:
0""falsenullundefinedNaN
This is called falsy. It is the most dangerous abstraction in the language.
The danger is that falsy conflates absence with valid-but-zero/empty. Those are different things, and "different things" is the entire reason types exist.
A counter at 0 is not absent. An empty string is a valid string. NaN is a real result in the number domain. false is the boolean answer to a question, not a missing one. null and undefined mean something else entirely.
function showCount(count) {
if (count) { // ← bug
document.body.append(`Total: ${count}`)
} else {
document.body.append("No items")
}
}
showCount(0) // "No items" ← wrong, there are 0 items, that IS a count
Every JavaScript developer has shipped this bug. It comes back in different shapes:
if (user.name) // fails on legit empty name
if (config.timeout) // fails on legit 0 timeout
if (response.body) // fails on legit empty-string body
if (array.length) // works (kind of) but only by coincidence
The fix is to write the actual question you meant:
if (count != null) // "is this absent?"
if (count > 0) // "is this positive?"
if (count !== 0) // "is this nonzero?"
if (typeof count === "number") // "is this a number at all?"
Each of those is a different question. if (count) answered all four at once and produced one answer for all four. That's not a feature; that's a category error baked into the language.
What strict TypeScript does fix, and what it doesn't
TypeScript's strictNullChecks is genuinely good. It forces you to type optionality explicitly (T | null, T | undefined) and rejects code that doesn't handle the absence case. It also gives you ?? (nullish coalescing) and ?. (optional chaining), which treat null and undefined together — an implicit acknowledgement from the language designers that distinguishing them was a mistake.
What strict TS does NOT fix:
-
nullandundefinedare still two distinct values. The type system tracks them; the runtime still has both. - The falsy rule is unchanged. TS does not touch JavaScript's runtime semantics.
if (count)still silently fails on0. The compiler will not warn you. Your linter might, if you've configured it. Most people have not.
TS strict is the best you can do without designing a new language. It is also not enough.
What scrml does
scrml is a new language, so it has the privilege of fixing both mistakes at the source.
There is one value for absence. It is called not. The keywords null and undefined are not valid identifiers in scrml source. Writing them is a compile error.
let x = not // ok
let x = null // E-SYNTAX-042
let x = undefined // E-SYNTAX-042
There is one way to check for absence. It is is not.
if (x is not) { handleAbsence() }
if (x is some) { handlePresence(x) }
There is no falsy rule. Boolean contexts accept booleans. The only false thing is false. The only absent thing is not. 0 is a number. "" is a string. They are not "false-ish" or "absent-ish" — they are zero and empty, respectively, and you have to ask the question you actually meant.
if (count is not) { ... } // "no value"
if (count == 0) { ... } // "zero"
if (count > 0) { ... } // "positive"
Three different questions. Three different answers. The language refuses to let you confuse them.
Narrowing is enforced by the type system. When a variable is T | not, you can't use it as T until you've handled the absence case. The way you do that is given:
${
given x => {
// x is T here, not T | not
// the | not has been narrowed away
use(x)
}
}
The given block runs only when x is some, and inside it the compiler narrows x to its non-absent type. Forgetting to handle absence is not "best practice"; it is a compile error.
For pattern matching:
${
match x {
not => handleAbsence()
given x => handlePresence(x)
}
}
Match on a T | not without a not arm? Compile error (E-MATCH-012). Exhaustiveness for absence is forced.
What about the JS interop?
The compiler emits plain JavaScript. JavaScript libraries hand back null and undefined indiscriminately. Doesn't this all fall apart at the boundary?
No. is not compiles to a check that catches both:
x is not → (x === null || x === undefined)
x is some → (x !== null && x !== undefined)
given x → if (x !== null && x !== undefined) { ... }
The chaos stays in the runtime. The source stays clean. When a JS library returns either, the absence check still works correctly. You write one form; the compiler emits the safe form for you.
The same applies to SQL results (?{}.get() returns T | not, not T | null), to optional fields, to function returns. Everywhere absence might come from, the language gives you one way to express it and one way to handle it.
"But this is more keystrokes than if (x)"
Yes. Eight characters more, in fact: if (x is some) vs if (x).
It is also a category of bug that does not exist in scrml programs. The line where you wrote if (count) and thought you handled zero correctly is the line that cost your team a day of debugging. The line where you wrote if (user) and the API returned null once for one user is the line that put a 500 in front of one customer for a week.
Languages do not have a moral obligation to be terse. They have a moral obligation to make wrong programs hard to write. JavaScript, by treating absence as identical to zero and empty and false in boolean contexts, made a particular class of wrong program very easy to write. We have been paying for that ever since with sentry alerts, post-mortems, and "I swear it worked locally."
The trade is a few extra characters for one fewer category of recurring bug. It is the most obvious trade in language design and it is the trade JavaScript could not make in 1995, because it had a ten-day deadline and was trying not to break the web.
We don't have that excuse anymore.
Try it / follow along
- Site: https://scrml.dev
-
Repo + spec: https://github.com/bryanmaclee/scrmlTS — see
compiler/SPEC.md§42 for the fullnotsemantics - Intro post: Introducing scrml
- The living compiler post: It's Alive — A Living Compiler
- X / Twitter: @BryanMaclee — there's a thread version of this argument up there if you prefer the rant in 14 tweets.
If null vs undefined has bitten you in the last month, I want to hear the story. If you think falsy is fine and I'm overreacting, I want to hear that too — there's a defence of the design choice and I haven't heard one that holds up under load yet.
Top comments (0)