DEV Community

Cover image for Null, False, and Unknown Are Not the Same Thing
Maggie Zhou | AI SaaS Maker
Maggie Zhou | AI SaaS Maker

Posted on

Null, False, and Unknown Are Not the Same Thing

Some of the hardest bugs in production begin with a condition that looks completely harmless:

if (!user.isVerified) {
showVerificationPrompt();
}
The code is easy to read. The problem is that isVerified may not mean one thing.

It could be false, which means the system checked and the user is not verified. It could be null, which means verification has not happened yet. Or it could be missing because an older API response does not know about the field at all.

JavaScript and many data formats allow those states to collapse into the same branch. The runtime sees a falsy value. The user experiences three different situations.

That gap between machine-friendly values and human meaning is where many “small” bugs come from.

A boolean is often a compressed data model
A boolean is useful when the domain genuinely has two states:

the feature is enabled
the feature is disabled
But real systems often have more:

enabled
disabled
not configured
temporarily unavailable
not yet evaluated
unavailable because the user lacks permission
When all of those states are represented as true or false, information disappears. Once information has been discarded at a boundary, later code cannot reliably reconstruct it.

This is why a boolean should be treated as a modeling decision, not just a convenient type.

Consider a notification preference:

type Preferences = {
productEmails?: boolean;
};
What does undefined mean here?

It might mean the user has never selected a preference. It might mean the field was omitted by a partial update. It might mean the database migration has not populated older records. Each interpretation leads to a different product behavior.

The type does not answer the question. It only makes the ambiguity possible.

The bug usually appears at a boundary
Most teams do not intentionally decide that null, false, and missing should mean the same thing. The collapse happens while data moves through the system.

An API serializer omits empty fields. A database driver returns null. A form library converts an empty checkbox into false. A JavaScript fallback uses || to provide a default. A validation layer accepts all of them because the field is technically optional.

Each step looks reasonable in isolation. Together, they erase the original meaning.

This pattern is especially common in partial updates:

function updateProfile(input: { marketingEmails?: boolean }) {
if (!input.marketingEmails) {
disableMarketingEmails();
}
}
The function is trying to handle a user who explicitly turned emails off. It also disables emails when the caller simply omitted the property.

A safer version distinguishes presence from value:

function updateProfile(input: { marketingEmails?: boolean }) {
if ("marketingEmails" in input) {
setMarketingEmails(input.marketingEmails === true);
}
}
That small check changes the contract. Missing now means “do not update,” while false means “turn it off.”

Unknown is a real state
Developers sometimes use null as if it were an embarrassing implementation detail. In many systems, it is more honest to treat unknown as a first-class state.

An account security check may be:

passed
failed
still running
A payment may be:

approved
declined
pending
A content moderation result may be:

allowed
blocked
not reviewed
The third state is not a variation of the other two. It changes what the interface should show, what actions are safe, and what the system is allowed to assume.

An explicit union often communicates this better than an optional boolean:

type VerificationStatus =
| "verified"
| "not_verified"
| "pending";
Now a switch statement can force the developer to consider every state:

function getVerificationMessage(status: VerificationStatus) {
switch (status) {
case "verified":
return "Your account is verified.";
case "not_verified":
return "Please complete verification.";
case "pending":
return "Verification is still in progress.";
}
}
The type is doing more than preventing a typo. It is preserving a piece of domain knowledge.

Defaults are decisions, not neutral repairs
Defaults are useful, but they are often introduced too early.

This code is familiar:

const retries = options.retries || 3;
It may be correct if 0 is invalid. It is wrong if 0 means “never retry.”

The nullish coalescing operator is more precise:

const retries = options.retries ?? 3;
But even that only distinguishes null and undefined from 0. It does not tell us whether a missing value should inherit a default, wait for configuration, or produce an error.

The real question is not “Which operator should I use?” It is “Who owns the meaning of absence?”

If the product has a documented default, apply it at a deliberate boundary. If the caller must make the decision, preserve the missing state until that caller can decide. If the value should never be absent, reject it instead of quietly inventing one.

Analysis tools need room for uncertainty
This issue is not limited to application settings. It appears anywhere software interprets imperfect input.

Audio analysis is a useful example. A system looking at a recording may identify a chord, fail to identify a chord, or return a low-confidence interpretation because the signal is noisy or layered. Treating every result as a definitive answer creates the same kind of bug as treating missing data as false.

For a creator trying to understand a progression, a Chord Finder is most useful when its result becomes a starting point for listening and editing, not an excuse to skip judgment. The same principle applies when comparing tools described as the best stem splitter: the meaningful question is not whether a category has one universal winner, but whether the output is clear enough for the next step in a specific workflow.

The interface should make uncertainty visible. “No result,” “not processed,” and “low confidence” should not all look like “nothing found.”

This is a general design rule:

If the system knows less than the user assumes, show that gap before it becomes a decision.

Test the missing cases on purpose
Many test suites cover positive and negative values:

expect(isAllowed(true)).toBe(true);
expect(isAllowed(false)).toBe(false);
That is a start, but it does not test the boundary where the bug lives.

Add cases for:

null
undefined
an omitted property
an empty string
an invalid enum value
a value from an older API version
a value that is still being calculated
The expected behavior should be explicit. For example, a partial update might require:

it("does not change the preference when the field is omitted", () => {
updateProfile({});
expect(currentPreference()).toBe("unchanged");
});

it("disables the preference when false is provided", () => {
updateProfile({ marketingEmails: false });
expect(currentPreference()).toBe("disabled");
});
These tests are not just regression protection. They document the difference between “not provided” and “provided as false.”

Property-based tests can help when the input space is larger. Contract tests are useful when multiple services serialize the same field differently. Type-level checks can prevent an unknown state from being passed into a function that expects a confirmed result.

The right test depends on where the ambiguity enters the system.

A practical review checklist
When reviewing a field that looks boolean, ask:

Can the value be missing?
If it is missing, does that mean unknown, unchanged, not applicable, or default?
Can the value be null in storage or over the wire?
Does an empty string carry a different meaning?
Is the field used for a command or for a description?
Does a partial update need to distinguish omission from false?
What should the interface show while the value is being calculated?
Which layer is responsible for applying the default?
Are older clients allowed to omit the field?
Is there a test for every meaningful state?
If the answers are unclear, the code is probably relying on a convention that exists only in someone’s head.

Better data makes better AI assistance too
AI coding tools can generate a clean implementation for a vague request. They are also likely to choose the most common two-state interpretation when the domain actually has three or four states.

That is not necessarily a model failure. It is often a specification failure.

An assistant can help expose the problem if the prompt includes questions such as:

List every possible state for this field.
Which states are currently being collapsed?
Show where null, false, and missing behave differently.
Suggest a type that preserves the distinction.
Add tests for omitted, null, false, and true values.
The quality of the answer improves when the task asks for analysis before implementation. A generated patch is easier to trust when the underlying states have been named first.

The smallest values can carry the most meaning
A missing field does not mean false. A pending result does not mean failure. An empty value does not automatically mean a default.

Those distinctions may look fussy until they reach a customer, an operator, or a future developer trying to understand why the system made a decision.

Good software preserves meaning across boundaries. It does not force every uncertain situation into a convenient boolean just because a boolean is easy to branch on.

The next time a condition reads if (!value), pause for a moment. Ask what information that expression is hiding.

The bug may not be in the branch. It may be in the decision to make three states look like two.

Top comments (0)