Zum Hauptinhalt springen

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 name key 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:

  • 0 retries can mean retries are disabled;
  • "" can mean an intentionally blank label;
  • [] can mean remove every role;
  • False can 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

TermMeaning
truthytreated as true in a boolean context
falsytreated as false in a boolean context
absencelack of a value
omittednot supplied
explicit nullintentionally supplied as no value
sentinela unique object representing a special state
fallbacka replacement value
short-circuitstop evaluating once the result is known
vacuous truthan all-condition true for an empty set
ambiguitymore 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 or incorrectly 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 None are 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:

  1. why truthiness is not validity;
  2. why is None is preferred;
  3. why missing and None can differ;
  4. when a sentinel is necessary;
  5. why or is not equivalent to TypeScript ??;
  6. why empty collections may carry business meaning.