Skip to content

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 as Ok/Err themselves.
  • 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.