Chapter 5 — Truth, Absence, and Sentinel Values
1. Opening problem
Consider this function:
def display_name(user: dict[str, object]) -> str:
return user.get("name") or "Anonymous"
It appears reasonable, but it merges several different states:
- the
namekey is missing; - the value is
None; - the value is an empty string;
- the value is another falsy object.
print(display_name({"name": ""}))
The result is "Anonymous", even though the caller explicitly supplied an empty string.
This chapter explains Python truthiness and shows how to preserve domain meaning instead of treating every falsy value as absent.
2. Truthiness
Python accepts objects directly in boolean contexts:
if value:
...
Common falsy values are:
False
None
0
0.0
0j
""
[]
()
{}
set()
range(0)
Everything else is normally truthy unless a custom class defines special truth behaviour.
A falsy value is not automatically invalid. In a domain, each of these may be legitimate:
0retries can mean retries are disabled;""can mean an intentionally blank label;[]can mean remove every role;Falsecan mean a feature is explicitly disabled.
Therefore:
if not value:
...
means more than “value is absent.” It means “every falsy state is treated the same.”
3. Explicit checks
Use truthiness when the domain truly cares about emptiness:
if not users:
return []
Use explicit comparison when a particular state matters:
if timeout is None:
timeout = 30
The following implementation is incorrect when 0 is valid:
timeout = configured_timeout or 30
It replaces both None and 0.
A precise version is:
timeout = (
30
if configured_timeout is None
else configured_timeout
)
TypeScript has nullish coalescing:
const timeout = configuredTimeout ?? 30;
Python has no exact built-in ?? operator. Use an explicit conditional.
4. None
None is Python's conventional object for “no value.”
Use identity comparison:
if result is None:
...
Avoid:
if result == None:
...
Equality can be customized:
class Strange:
def __eq__(self, other: object) -> bool:
return True
value = Strange()
print(value == None) # True
print(value is None) # False
None is a singleton. Identity communicates the intended check directly.
5. Missing is not the same as None
Suppose an update method has this signature:
def update_user(
nickname: str | None = None,
) -> None:
...
These two calls are indistinguishable:
update_user()
update_user(nickname=None)
But the business meaning may be:
- omitted argument: leave the nickname unchanged;
- explicit
None: remove the nickname; - string: replace the nickname.
A sentinel preserves all three states.
MISSING = object()
def update_user(
nickname: str | None | object = MISSING,
) -> None:
if nickname is MISSING:
print("Unchanged")
elif nickname is None:
print("Clear nickname")
else:
print(f"Set nickname to {nickname}")
Compare sentinels by identity.
6. A descriptive sentinel
A plain object() has an unhelpful representation. A private sentinel type is clearer:
from typing import Final
class _MissingType:
__slots__ = ()
def __repr__(self) -> str:
return "MISSING"
MISSING: Final = _MissingType()
Usage:
def update_theme(
theme: str | None | _MissingType = MISSING,
) -> None:
if theme is MISSING:
return
if theme is None:
clear_theme()
return
set_theme(theme)
A sentinel must be unique. Do not use an ordinary domain value such as "", 0, or -1 as a missing marker.
7. Boolean operators return operands
and and or do not always return bool.
print("" or "fallback") # fallback
print("ready" or "fallback") # ready
print("ready" and 42) # 42
print("" and 42) # ""
or returns the first truthy operand, or the last operand.
and returns the first falsy operand, or the last operand.
This makes concise fallback expressions possible:
display_name = supplied_name or "Anonymous"
But it is correct only when all falsy values should trigger the fallback.
8. Short-circuit evaluation
Python stops evaluating as soon as the result is known:
if user is not None and user.is_active:
...
When user is None, user.is_active is not evaluated.
Short-circuiting is useful, but long chains become difficult to maintain:
user and user.profile and user.profile.email and send(user.profile.email)
Prefer explicit control flow:
if user is None:
return
profile = user.profile
if profile is None:
return
if profile.email is None:
return
send(profile.email)
The longer version makes failure points visible.
9. Empty values carry domain meaning
Consider role updates:
def update_roles(
roles: list[str] | None,
) -> None:
...
Possible meanings:
None: do not change roles;[]: remove every role;- non-empty list: replace all roles.
This distinction is useful. Do not normalize an empty collection to None unless the domain explicitly says they mean the same thing.
10. any() and all()
any() checks whether at least one item is truthy:
has_admin = any(user.is_admin for user in users)
all() checks whether every item is truthy:
all_active = all(user.is_active for user in users)
Important empty-input behaviour:
print(any([])) # False
print(all([])) # True
all([]) is true because no element violates the condition. This is called vacuous truth.
Sometimes that is mathematically correct but wrong for the business rule:
all_active = bool(users) and all(
user.is_active for user in users
)
Now the result requires at least one user.
11. Custom truthiness
A class can define __bool__:
class ValidationResult:
def __init__(self, errors: list[str]) -> None:
self.errors = errors
def __bool__(self) -> bool:
return not self.errors
Usage:
result = ValidationResult([])
if result:
print("Valid")
A class may also define __len__. Zero length is falsy.
Custom truthiness should be:
- inexpensive;
- side-effect free;
- easy to predict;
- semantically obvious.
Bad design:
class DatabaseConnection:
def __bool__(self) -> bool:
return self.ping_remote_server()
A simple if connection: now performs network I/O.
For domain objects, explicit properties are often clearer:
if result.is_valid:
...
12. PATCH-style API case study
An HTTP profile update may need three states:
- key absent: preserve current value;
- key present with
null: clear value; - key present with a string: replace value.
class _MissingType:
__slots__ = ()
MISSING = _MissingType()
def read_patch_field(
payload: dict[str, object],
key: str,
) -> object:
if key not in payload:
return MISSING
return payload[key]
Application logic:
display_name = read_patch_field(
payload,
"display_name",
)
if display_name is MISSING:
pass
elif display_name is None:
profile.clear_display_name()
elif isinstance(display_name, str):
profile.set_display_name(display_name)
else:
raise ValueError(
"display_name must be a string or null"
)
This is safer than:
display_name = payload.get("display_name")
because .get() merges missing and explicit None.
13. TypeScript comparison
TypeScript often uses:
function updateName(
name?: string | null
): void {
if (name === undefined) {
// unchanged
} else if (name === null) {
// clear
} else {
// set
}
}
Python uses a sentinel for the undefined-like state:
def update_name(
name: str | None | _MissingType = MISSING,
) -> None:
...
The concepts are similar, but Python does not provide a normal undefined value.
14. Common mistakes
Mistake 1: Using or as nullish coalescing
retry_count = configured_count or 3
This overwrites 0.
Mistake 2: Treating empty as missing
if not roles:
roles = default_roles
An empty list may intentionally mean no roles.
Mistake 3: Comparing None with ==
Use identity.
Mistake 4: Using a normal value as a sentinel
Never use an otherwise valid value.
Mistake 5: Expensive __bool__
Boolean conversion should not trigger I/O.
Mistake 6: Using .get() when missing and None differ
Check key membership explicitly.
15. English vocabulary
| Term | Meaning |
|---|---|
| truthy | treated as true in a boolean context |
| falsy | treated as false in a boolean context |
| absence | lack of a value |
| omitted | not supplied |
| explicit null | intentionally supplied as no value |
| sentinel | a unique object representing a special state |
| fallback | a replacement value |
| short-circuit | stop evaluating once the result is known |
| vacuous truth | an all-condition true for an empty set |
| ambiguity | more than one possible interpretation |
Useful sentences:
- “The implementation collapses several distinct states.”
- “Zero is a legitimate domain value.”
- “A sentinel distinguishes omission from explicit clearing.”
- “This use of
orincorrectly treats an empty string as absent.” - “Truth conversion should be cheap and side-effect free.”
16. Speaking task
Explain for six minutes:
Why falsy, missing, and
Noneare not equivalent.
Include a TypeScript comparison and one PATCH endpoint example.
17. Writing task
Write a 300-word design note explaining how a profile endpoint should handle:
- missing
display_name; display_name: null;display_name: "";- a normal string.
18. Exercises
Exercise 1
Predict:
print(None or "default")
print(0 or 100)
print("" or "anonymous")
print([] or ["fallback"])
print("ready" and 42)
Exercise 2
Fix:
def resolve_timeout(
timeout: int | None,
) -> int:
return timeout or 30
A timeout of zero must be preserved.
Exercise 3
Implement a sentinel-based settings update with keyword-only theme and notifications.
Exercise 4
Create a Batch class that is truthy when it contains at least one item.
Exercise 5
Write checks for:
- at least one administrator;
- every user active;
- no suspended user.
19. Complete solutions
Solution 1
default
100
anonymous
['fallback']
42
Solution 2
def resolve_timeout(
timeout: int | None,
) -> int:
return 30 if timeout is None else timeout
Solution 3
class _MissingType:
__slots__ = ()
MISSING = _MissingType()
def update_settings(
*,
theme: str | None | _MissingType = MISSING,
notifications: bool | None | _MissingType = MISSING,
) -> None:
if theme is MISSING:
print("Theme unchanged")
elif theme is None:
print("Theme cleared")
else:
print(f"Theme set to {theme}")
if notifications is MISSING:
print("Notifications unchanged")
elif notifications is None:
print("Preference cleared")
else:
print(
f"Notifications set to {notifications}"
)
Solution 4
class Batch:
def __init__(
self,
items: list[str] | None = None,
) -> None:
self._items = (
[] if items is None else list(items)
)
def __bool__(self) -> bool:
return bool(self._items)
def add(self, item: str) -> None:
self._items.append(item)
Solution 5
has_admin = any(
user.is_admin for user in users
)
all_active = all(
user.is_active for user in users
)
none_suspended = not any(
user.is_suspended for user in users
)
20. Chapter checkpoint
You should now be able to explain:
- why truthiness is not validity;
- why
is Noneis preferred; - why missing and
Nonecan differ; - when a sentinel is necessary;
- why
oris not equivalent to TypeScript??; - why empty collections may carry business meaning.