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:

  1. Any — return value as-is

  2. Injection — run the validation/transformer chain (Validation & Injection)

  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

_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:

  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 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 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) → 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:

# 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.