Coercion¶
Value coercion in EDRI converts raw incoming strings and JSON types to the Python types declared by event dataclass fields. This happens in the API Listener (and optionally in the Broker) before the event reaches a Manager handler.
The coercion logic lives in TypeCoercer at edri.utility.coercion. It is intentionally stateless and process-agnostic — usable both inside the ASGI Listener process and in non-API contexts (e.g. coercing WebSocket subscription filter values).
convert_type() — entry point¶
convert_type(value, annotation)() resolves the annotation and dispatches to the appropriate conversion strategy:
Any— return value as-isInjection— run the validation/transformer chain (Validation & Injection)Union/Optional/X | Y— try each member in order, return the first that succeedsGeneric (
list[T],dict[K,V],tuple[X,Y],Literal[...], list-like subclasses) — convert elements recursivelySimple types (
bool,int,str,float, dataclasses, custom classes) — constructor orfromisoformat
Supported type targets¶
Primitives¶
Target |
Behaviour |
|---|---|
|
|
|
|
|
String mapping: |
|
|
Optional[T] and Union[T, ...]¶
Optional/Union annotations (including X | Y | None syntax) are resolved by trying each member in declaration order. None is accepted only when NoneType is a member. If the value is None and the union does not include NoneType, a TypeError is raised.
Empty strings are not automatically converted to None — if you need that behaviour, wrap the field in inject(…) | None.
Literal["a", "b"]¶
Value must be an exact member of the literal set. Membership is checked with in, so types must match.
Generics¶
Annotation |
Behaviour |
|---|---|
|
Each element is converted to |
|
Zipped positional conversion against the tuple's type arguments |
|
Each key and value converted via |
datetime and date¶
_convert_simple() first tries annotation(value) (the standard constructor), then falls back to annotation.fromisoformat(value) if the constructor raises an exception.
ISO duration¶
datetime.timedelta is supported via Python's fromisoformat() (Pythion 3.7+). The standard ISO 8601 duration format is accepted (e.g. "P4DT12H30M5S").
Integration with Injection and validation¶
Coercion and validation form a two-layer pipeline:
Basic coercion —
convert_typeconverts the raw string to the declared Python type (e.g."42"→42).Injection — if the annotation is wrapped in
inject(…), the coercion output is fed through the validator/transformer chain defined inInjection.
When both are present, the field annotation inject(SomeValidator, …) | None triggers:
convert_type(value, Injection(…))→ handles the| Nonewrapper_convert_injectionruns each validator in order with the filtered parameters
See Validation & Injection for details on validators and the inject() helper.
How to write a custom coercion for a custom type¶
Define a class that accepts a single value in its constructor (or implements fromisoformat):
from dataclasses import dataclass
class HexColor:
def __init__(self, value: str) -> None:
if not isinstance(value, str) or not value.startswith("#"):
raise TypeError(f"not a hex color: {value!r}")
self.rgb = value
# Now usable as an event field type:
# class MyEvent(Event):
# color: HexColor
For types that need conversion from multiple formats, implement fromisoformat (the method name is a convention — it works for any format, not just ISO):
class PortNumber:
def __init__(self, value: int) -> None:
if not 1 <= value <= 65535:
raise TypeError(f"invalid port: {value}")
self.value = value
@classmethod
def fromisoformat(cls, value: str) -> "PortNumber":
return cls(int(value))
The coercer tries annotation(value) first, then falls back to annotation.fromisoformat(value).
Edge cases¶
Booleans from strings¶
String-to-boolean coercion is the most notable special case. The _convert_bool() method accepts:
"true","1","yes","on"(case-insensitive, stripped) →True"false","0","no","off"(case-insensitive, stripped) →Falseint0 or 1 →boolbool→ passed through
All other values raise TypeError.
Empty strings → None¶
Coercion does not automatically convert empty strings to None. Use inject(…) | None with a validator that maps "" to None, or handle it in your handler.
Nested generics¶
Nested generics are handled via recursive convert_type calls:
# list[list[int]] — each inner element is converted to list[int]
value = [[1, 2], [3, 4]]
Union resolution order¶
Unions are tried left-to-right. The first member that accepts the value wins. This means str | int will always match strings before attempting integer conversion. Order your union members from most-to-least specific.