How to Handle Null Values with Maybe¶
This guide covers practical patterns for replacing None-returning code with Maybe, propagating absence through pipelines, and safely extracting values — without scattering if x is not None checks across your codebase.
Prerequisites¶
Familiarity with
Maybe.Just/Maybe.Nothingand the|operator
Wrapping a function that returns None¶
Convert any function that returns None on absence into one that returns Maybe:
from katharos.types import Maybe
def find_user(uid: int) -> Maybe[dict]:
db = {
1: {"name": "Alice", "department": "engineering"},
2: {"name": "Bob"},
}
user = db.get(uid)
return Maybe[dict].Just(user) if user is not None else Maybe[dict].Nothing()
Call it the same way you would before — but now the absence is explicit in the type:
find_user(1) # Just({'name': 'Alice'})
find_user(99) # Nothing()
Transforming a value inside Maybe¶
Use fmap to apply a function to the wrapped value. If the Maybe is Nothing, fmap does nothing and propagates the absence:
find_user(1).fmap(lambda u: u["name"]) # Just('Alice')
find_user(99).fmap(lambda u: u["name"]) # Nothing()
Chaining operations that may also be absent¶
Use | (bind) when the next step itself returns a Maybe. The chain short-circuits on the first Nothing:
def get_email(user: dict) -> Maybe[str]:
email = user.get("email")
return Maybe[str].Just(email) if email is not None else Maybe[str].Nothing()
find_user(1) | get_email # Nothing() — Alice has no email field
find_user(99) | get_email # Nothing() — user not found
Providing a default when absent¶
Check the result with is_just() and call unwrap() to get the value, or supply a fallback:
result = find_user(1).fmap(lambda u: u["name"])
name = result.unwrap() if result.is_just() else "Guest"
print(name) # Alice
result = find_user(99).fmap(lambda u: u["name"])
name = result.unwrap() if result.is_just() else "Guest"
print(name) # Guest
Flattening multi-step lookups with do-notation¶
When several steps each return a Maybe and each depends on the previous result, use the @do decorator to write them as a plain sequence. The first Nothing short-circuits the whole block:
from katharos.syntax_sugar import do, DoBlock
def get_department(user: dict) -> Maybe[str]:
dept = user.get("department")
return Maybe[str].Just(dept) if dept is not None else Maybe[str].Nothing()
def get_budget(dept: str) -> Maybe[int]:
budgets = {"engineering": 50000, "marketing": 30000}
b = budgets.get(dept)
return Maybe[int].Just(b) if b is not None else Maybe[int].Nothing()
@do(Maybe)
def user_budget(uid: int) -> DoBlock[int]:
user: dict = yield find_user(uid)
dept: str = yield get_department(user)
budget: int = yield get_budget(dept)
return budget
user_budget(1) # Just(50000) — Alice is in engineering
user_budget(2) # Nothing() — Bob has no department
user_budget(99) # Nothing() — user not found
Combining multiple lookups¶
When a result depends on several lookups all succeeding, use @do rather than
checking is_just() on each result manually:
@do(Maybe)
def greet(uid: int, greeting_id: int) -> DoBlock[str]:
greetings = {1: "Hello", 2: "Hi"}
def get_greeting(g_id: int) -> Maybe[str]:
g = greetings.get(g_id)
return Maybe[str].Just(g) if g is not None else Maybe[str].Nothing()
user: dict = yield find_user(uid)
greeting: str = yield get_greeting(greeting_id)
return f"{greeting}, {user['name']}!"
greet(1, 1) # Just('Hello, Alice!')
greet(1, 99) # Nothing() — greeting not found
greet(99, 1) # Nothing() — user not found