# 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 {class}`~edri.utility.coercion.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 {meth}`~edri.utility.coercion.TypeCoercer.convert_type(value, annotation)` resolves the annotation and dispatches to the appropriate conversion strategy: 1. **`Any`** — return value as-is 2. **`Injection`** — run the validation/transformer chain ({doc}`validation`) 3. **`Union` / `Optional` / `X | Y`** — try each member in order, return the first that succeeds 4. **Generic** (`list[T]`, `dict[K,V]`, `tuple[X,Y]`, `Literal[...]`, list-like subclasses) — convert elements recursively 5. **Simple types** (`bool`, `int`, `str`, `float`, dataclasses, custom classes) — constructor or `fromisoformat` --- ## Supported type targets ### Primitives | Target | Behaviour | |--------|-----------| | `int` | `int(value)` | | `float` | `float(value)` | | `bool` | String mapping: `"true"`, `"1"`, `"yes"`, `"on"` → `True`; `"false"`, `"0"`, `"no"`, `"off"` → `False`; integer `0`/`1` also accepted | | `str` | `str(value)` | ### `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 | |------------|-----------| | `list[T]` | Each element is converted to `T` via `convert_type` | | `tuple[X, Y, …]` | Zipped positional conversion against the tuple's type arguments | | `dict[K, V]` | Each key and value converted via `convert_type` | ### `datetime` and `date` {meth}`~edri.utility.coercion.TypeCoercer._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 {func}`~datetime.timedelta.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: 1. **Basic coercion** — `convert_type` converts the raw string to the declared Python type (e.g. `"42"` → `42`). 2. **Injection** — if the annotation is wrapped in `inject(…)`, the coercion output is fed through the validator/transformer chain defined in {class}`~edri.dataclass.injection.Injection`. When both are present, the field annotation `inject(SomeValidator, …) | None` triggers: - `convert_type(value, Injection(…))` → handles the `| None` wrapper - `_convert_injection` runs each validator in order with the filtered parameters See {doc}`validation` 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`): ```python 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): ```python 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 {meth}`~edri.utility.coercion.TypeCoercer._convert_bool` method accepts: - `"true"`, `"1"`, `"yes"`, `"on"` (case-insensitive, stripped) → `True` - `"false"`, `"0"`, `"no"`, `"off"` (case-insensitive, stripped) → `False` - `int` 0 or 1 → `bool` - `bool` → 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: ```python # 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.