ADR-053: Semantics of T | None optional dependency injection¶
Status¶
Accepted Date: 2026-08-08
Context¶
ADR-046 (Typed Handler Contract Validation) established the Depends(), Payload(), and Topic() binding marker family and the DI-validation-must-be-loud philosophy: every unresolvable dependency must fail at registration time, not silently at runtime. ADR-006 (Hexagonal Architecture) governs adapter resolution: adapters are concrete provider instances registered by type. The framework already uses Annotated[T | None, Payload()] as the idiomatic way to declare an optional typed payload — this is the key precedent the present decision builds on.
Today in packages/src/cosalette/_injection.py, an injected handler parameter must resolve to a concrete provider type. Two consequences follow:
T | None(e.g.store: DeviceStore | None) is rejected at registration because a union hasget_origin(...) is types.UnionTypeand fails theisinstance(annotation, type)check in_resolve_annotation.param.defaultis ignored entirely — the injection plan is(name, type)pairs only.
Net effect: there is no way to express "inject this dependency if a provider is registered, otherwise leave it unset." Users who need a genuinely optional dependency must currently hand-register a null-object adapter — unnecessary boilerplate that this ADR resolves.
T | None has several conflicting plausible meanings in a DI context; picking one silently is exactly how DI frameworks acquire surprising behaviour. This ADR answers four questions explicitly:
- Optionality opt-in vs. inferred — is optionality inferred from
T | Nonesyntax, or does it require an explicit opt-in marker? param.defaultparticipation — does the default value participate in resolution, and what happens when there is no default?- Standalone vs. composable — may the optionality marker be combined with
Depends(),Payload(), orTopic()? - Semantic disambiguation — is "no provider registered" the same as "provider registered but resolved to None", and does
T | Noneon a handler parameter mean the same as on a provider return type?
Decision¶
Adopt an explicit Optional() binding marker for optional dependency injection — Annotated[T | None, Optional()] (or Annotated[T, Optional()]) resolves the provider if registered and otherwise falls back to the parameter default (implicitly None) — because it fills the optional-injection gap while preserving the loud-failure guarantee that DI validation exists to provide, and it stays consistent with the existing Depends/Payload/Topic marker family.
Sub-decision 1 — Optionality is opt-in. Bare T | None on an injected parameter remains rejected at registration, now with an improved diagnostic that redirects to Annotated[T, Optional()] (for optional DI) or Annotated[T, Payload()] (for payloads). Optionality is never inferred from type syntax alone.
Sub-decision 2 — param.default participates. On an Optional() parameter, when no provider resolves, the framework injects the explicit default if one is present; if there is no default, None is injected implicitly. There is no registration error for a missing default on an Optional() parameter.
Sub-decision 3 — Optional() is standalone. Optional() may not be combined with Depends(), Payload(), or Topic() on the same parameter. Multiple binding markers on one parameter is a registration TypeError.
Sub-decision 4 — "No provider" vs. "resolved to None" are distinct. Providers in the resolution map are always concrete instances (never None), so a plain-type Optional() parameter only ever yields None from the no-provider fallback. A Depends(callable) whose callable returns None injects that None as-is — that is the callable's explicit choice and is unaffected by this ADR. T | None on a provider or Depends return type retains its ordinary Python meaning; T | None on a handler parameter only carries optional-injection semantics when combined with the Optional() marker.
from typing import Annotated
from cosalette import App
from cosalette.di import Optional
from myapp.stores import DeviceStore
app = App("demo")
@app.command("update_setpoint")
async def handle_update(
store: Annotated[DeviceStore | None, Optional()], # inject if registered, else None
store_with_default: Annotated[DeviceStore | None, Optional()] = DeviceStore.null(),
) -> None:
if store is None:
return # adapter not registered in this deployment
...
# REJECTED — bare union without Optional() marker:
# async def bad_handler(store: DeviceStore | None) -> None: ...
# Registration error: use Annotated[DeviceStore | None, Optional()] for optional DI
Decision Drivers¶
- Preserve the loud-failure guarantee that ADR-046-era DI validation exists to provide: a registration typo (e.g. misspelled adapter type) must not silently become None and cause an AttributeError deep in the handler at runtime.
- Consistency with the established marker family (Depends/Payload/Topic) and the existing
Annotated[T | None, Payload()]optional-payload precedent: optionality is always expressed via a marker, never inferred from type syntax. - Optionality should be explicit and greppable rather than inferred from punctuation (PEP 20: explicit is better than implicit);
grep 'Optional('surfaces all optional-injection sites immediately. - Type-checker honesty: the declared type must match the possible runtime value;
Annotated[T | None, Optional()]is honest because the handler genuinely may receiveNone. - Minimise surprising silent behaviour in dependency resolution: the same
T | Noneannotation should not behave differently depending on whether a default is present. - Keep public API surface growth justified by the capability it unlocks:
Optional()fills a real gap (optional adapters) and the cost is proportionate to the benefit.
Considered Options¶
Option 1: Explicit Optional() marker (chosen)¶
Add a new public binding marker Optional() as a sibling of Depends() in cosalette/di.py. Usage: Annotated[T | None, Optional()] (or Annotated[T, Optional()]). The framework strips | None from the inner type, resolves T against the providers map, and injects the resolved instance if found; otherwise it injects param.default if present, else None implicitly. Bare T | None without a marker remains rejected but now emits an improved diagnostic pointing to Annotated[T, Optional()]. Optional() is standalone and may not be combined with Depends(), Payload(), or Topic().
- Advantages: Preserves the loud-failure guarantee from ADR-046: a typo'd provider registration still errors loudly instead of silently becoming None.; Consistent with the existing Depends/Payload/Topic marker family and the
Annotated[T | None, Payload()]optional-payload precedent already in use.; Optionality is opt-in and greppable (grep 'Optional('finds all optional-injection sites across the codebase).; Type-checker honest: the inner type is genuinelyT | None, matching the possible runtime value. - Disadvantages: Adds one more public marker to learn, document, test, and maintain.; More verbose than a bare union at the call site.; Marker-validation logic must be extended to recognise
Optional()and enforce the standalone constraint.
Option 2: Infer optionality from union, inject None¶
Make bare T | None = None legal without any marker. When no provider is registered for T, the framework injects None. The default value is used as the fallback if present; otherwise None is used implicitly.
- Advantages: Zero new API surface; no new marker to document or test.; Reads the way most Python developers expect: a union with None means the value may be absent.; Least verbose at the call site.
- Disadvantages: Reintroduces the exact silent-failure mode that ADR-046 DI validation exists to prevent: a misspelled adapter registration silently yields None causing an AttributeError deep in the handler at runtime.; Cannot distinguish 'operator forgot to register the adapter' from 'intentionally optional dependency'.; Diverges from the established
Annotated[T | None, Payload()]marker-governed precedent, creating an inconsistent API surface.
Option 3: Infer optionality from union, require explicit default¶
Like Option B, but a default value is mandatory; the absence of a default on a T | None parameter is a registration error. The framework falls back only to the explicit default, never injects None implicitly.
- Advantages: The fallback value is always visible at the call site — no implicit None.; No new marker; optionality is expressed through type syntax plus a required default.; Requiring an explicit default is a mild forcing function toward intentionality.
- Disadvantages: Infers optionality from type syntax, so the silent-typo-to-fallback failure mode remains: a misspelled adapter registration causes the default to be used instead of a loud error.;
T | Noneacquires different semantics depending on whether a default is present, making the annotation meaning context-dependent.; Handler-vs-providerT | Nonedivergence is unaddressed: the annotation means different things in different positions.
Option 4: Keep rejecting, improve the message only¶
Retain the current behaviour of rejecting T | None at registration and improve the diagnostic to clearly explain why the union is rejected and how to work around it (e.g. hand-register a null-object adapter). No new API is introduced.
- Advantages: Cheapest implementation: no new surface, no new validation logic, fully reversible.; Zero regression risk: existing behaviour is unchanged.; Leaves the optional-injection decision open for a future ADR with more implementation experience.
- Disadvantages: Leaves the 'inject if available' capability gap unfilled; users must hand-register a null-object adapter for every optional dependency.; Does not answer the four semantic questions, deferring the problem rather than resolving it.
Decision Matrix¶
| Criterion | Explicit Optional() marker | Infer optionality from union, inject None | Infer optionality from union, require explicit default | Keep rejecting, improve the message only |
|---|---|---|---|---|
| Loud on registration typo | 5 | 2 | 2 | 5 |
| No new API surface | 2 | 5 | 5 | 5 |
| Greppable / explicit optionality | 5 | 2 | 2 | 1 |
| Consistency with existing marker precedent | 5 | 2 | 2 | 3 |
| Type-checker honesty | 5 | 5 | 5 | 2 |
| Fills the capability gap | 5 | 5 | 5 | 2 |
Scale: 1 (poor) to 5 (excellent)
Consequences¶
Positive¶
- Fills the 'inject if available' capability gap without weakening DI validation: handlers can declare optional adapter dependencies, and registration typos still fail loudly.
- Optional dependencies become greppable and explicit:
grep 'Optional('surfaces every optional-injection site across the codebase. - Reuses the established marker family (Depends/Payload/Topic/Optional) — no new mental model is required for users already familiar with Annotated-based DI.
- Type-checkers see the honest
T | Nonedeclared type, enabling correct static analysis of handler parameters that may receiveNone.
Negative¶
- Adds one more public marker (
Optional()) to learn, document, test, and maintain; the public API surface grows by one binding marker. - More verbose than a bare union at the call site:
Annotated[T | None, Optional()]versusT | None = None. - The marker-validation path must be extended to recognise
Optional()and enforce the standalone constraint; the implementation cost is tracked in beads task cos-v1dj.7.
2026-08-08