Optional[T] where Result[T, E] would distinguish failure modes¶
The corpus prior¶
Tutorials and Stack Overflow code overwhelmingly model failure as None. "What if the user doesn't exist?" → return None. "What if the JSON is malformed?" → return None. "What if the schema is wrong?" → return None. The corpus contains thousands of examples of def f(x) -> Foo | None: with the body collapsing every distinct failure into a return None.
The pattern compounds because once a function returns T | None, the next agent extending it adds another failure path the cheapest way available: another return None. The caller's downstream type narrowing then has no way to distinguish why the value is None.
The shape arrives in agent-generated code because the corpus's bias for Optional[T] is overwhelming, and the agent's job is "make this function compile and return something sensible." None is the cheapest sensible return — even when the failure carries information the caller would want.
Wrong shape¶
def parse_event(text: str) -> Event | None:
if not text:
return None # empty input
try:
data = json.loads(text)
except json.JSONDecodeError:
return None # parse failure
try:
return Event.model_validate(data)
except ValidationError:
return None # schema violation
Three distinct failure modes — empty input, parse failure, schema violation — all collapsed into the same None. Caller code:
event = parse_event(text)
if event is None:
# which failure was it? We can't tell. Log generically and hope.
log.warning("parse_event failed for input of length %d", len(text))
The caller cannot log "parse failed at line X" vs "empty input" vs "schema validation failed because the created_at field is missing." All three are erased into indistinguishable None.
Right shape¶
Use Result[Event, ParseError] with a tagged error union:
from dataclasses import dataclass
from dazzle.result import Err, Ok, Result
@dataclass(frozen=True, slots=True)
class EmptyInput: ...
@dataclass(frozen=True, slots=True)
class MalformedJson:
detail: str
@dataclass(frozen=True, slots=True)
class SchemaViolation:
field: str
detail: str
type ParseError = EmptyInput | MalformedJson | SchemaViolation
def parse_event(text: str) -> Result[Event, ParseError]:
if not text:
return Err(EmptyInput())
try:
data = json.loads(text)
except json.JSONDecodeError as e:
return Err(MalformedJson(detail=str(e)))
try:
return Ok(Event.model_validate(data))
except ValidationError as e:
first = e.errors()[0]
return Err(SchemaViolation(field=str(first["loc"][0]), detail=first["msg"]))
Caller code:
match parse_event(text):
case Ok(event):
process(event)
case Err(EmptyInput()):
log.warning("empty input — skipping")
case Err(MalformedJson(detail=d)):
log.error("parse failure: %s", d)
case Err(SchemaViolation(field=f, detail=d)):
log.error("schema error on %s: %s", f, d)
The three failure modes are now distinguishable at the call site, and the type checker enforces exhaustive handling.
Convention for error types:
- Use
@dataclass(frozen=True, slots=True)for error variants — same shape asOk/Errthemselves. - Tagged unions via
type ParseError = X | Y | Z(PEP 695). - Don't use plain strings as error tokens —
Err("not found")defeats the type-distinction purpose. Use a frozen dataclass even if empty (it's the variant name that carries semantic content).
When T | None is genuinely fine:
The catalogue isn't anti-Optional — Optional has a real use. T | None is correct when there's exactly one failure mode that the caller never needs to distinguish from another. Examples: dict.get(k) returning None for "key not found" (the only possible outcome); a cache lookup returning None for "miss" (the only outcome). The wrong shape emerges when two-or-more failure modes get folded into the same None.
Why this matters here¶
Dazzle's framework code paths already practise this discipline (the IR's LinkError / ValidationError / ParseError hierarchy is the framework-layer equivalent — distinct error types, not all collapsed into None). User-app code in app/ doesn't yet have an idiomatic answer, so agents reach for the corpus default: T | None.
PA-LLM-09 flags the wrong shape at scan time. dazzle.result makes the right shape one import away. The catalogue bridges the gap at inference time — bootstrap and knowledge counter_prior surface this entry when an agent is about to write a multi-failure-mode function.
unwrap() deliberately re-introduces an exception (UnwrapError). That's by design — unwrap() belongs at clearly designated boundaries (CLI entry points, top-level request handlers, test code), not at the inflow consumption point. Inside business logic, the match form is the idiom. The catalogue's boundary advice complements the library's safety: the library makes the wrong usage possible; the catalogue makes the right usage obvious.