# Type Handler System Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Replace validation-only `register_type` with a handler system that bundles validation, serialization, and deserialization — extensible for future behaviors.

**Architecture:** Add `TypeHandler` Protocol and `BaseTypeHandler` class to `types.py`. Refactor `TypeRegistry.register()` to accept `handler=`, `serialize=`, `deserialize=` kwargs. Add `get_handler()` for serialize.py to look up handlers. Update serialization to call handlers when available.

**Tech Stack:** Python >= 3.13, pytest, mypy, ty, ruff

**Spec:** `docs/superpowers/specs/2026-03-21-type-handler-system-design.md`

**Key context for implementers:**
- Read `CLAUDE.local.md` before starting
- `deserialize` is required when providing kwargs. `serialize` alone raises `ValueError`.
- `handler=` and kwargs are mutually exclusive
- Built-in types (str, int, etc.) have NO handler — `get_handler()` returns `None` for them
- Backwards compatible: `register_type(MyType)` still works (identity handler)

---

## File Map

| File | Responsibility |
|------|---------------|
| `src/cendry/types.py` | `TypeHandler` Protocol, `BaseTypeHandler`, `_KwargsHandler`, refactored `TypeRegistry` |
| `src/cendry/serialize.py` | Check `get_handler()` in deserialization and serialization paths |
| `src/cendry/__init__.py` | Export `TypeHandler`, `BaseTypeHandler` |
| `CLAUDE.local.md` | Document handler registration rule |
| `tests/test_types.py` | Unit tests for handlers |
| `tests/test_handler_integration.py` | **New** — integration tests for handler + serialization |

---

### Task 1: TypeHandler Protocol + BaseTypeHandler + _KwargsHandler

**Files:**
- Modify: `src/cendry/types.py`
- Modify: `tests/test_types.py`

- [ ] **Step 1: Write failing tests for TypeHandler classes**

```python
# append to tests/test_types.py

from cendry.types import BaseTypeHandler, TypeHandler


def test_base_type_handler_serialize_identity():
    handler = BaseTypeHandler()
    assert handler.serialize(42) == 42


def test_base_type_handler_deserialize_identity():
    handler = BaseTypeHandler()
    assert handler.deserialize("hello") == "hello"


def test_base_type_handler_satisfies_protocol():
    handler = BaseTypeHandler()
    assert isinstance(handler, TypeHandler)
```

- [ ] **Step 2: Implement TypeHandler, BaseTypeHandler, _KwargsHandler**

Add to `src/cendry/types.py` before `TypeRegistry`:

```python
from typing import Protocol, runtime_checkable

@runtime_checkable
class TypeHandler(Protocol):
    """Protocol defining the contract for type handlers."""

    def serialize(self, value: Any) -> Any: ...
    def deserialize(self, value: Any) -> Any: ...


class BaseTypeHandler:
    """Base class with sensible defaults.

    Subclass and override what you need. New methods can be added
    in the future without breaking existing handlers.
    """

    def serialize(self, value: Any) -> Any:
        return value

    def deserialize(self, value: Any) -> Any:
        return value


class _KwargsHandler(BaseTypeHandler):
    """Internal handler created from serialize=/deserialize= kwargs."""

    def __init__(
        self,
        serialize_fn: Callable[[Any], Any] | None = None,
        deserialize_fn: Callable[[Any], Any] | None = None,
    ) -> None:
        self._serialize_fn = serialize_fn
        self._deserialize_fn = deserialize_fn

    def serialize(self, value: Any) -> Any:
        if self._serialize_fn is not None:
            return self._serialize_fn(value)
        return value

    def deserialize(self, value: Any) -> Any:
        if self._deserialize_fn is not None:
            return self._deserialize_fn(value)
        return value
```

- [ ] **Step 3: Run tests, commit**

```bash
uv run pytest tests/test_types.py -v -k "handler"
git commit -am "feat: add TypeHandler Protocol, BaseTypeHandler, _KwargsHandler"
```

---

### Task 2: Refactor TypeRegistry.register() with new signature

**Files:**
- Modify: `src/cendry/types.py`
- Modify: `tests/test_types.py`

- [ ] **Step 1: Write failing tests for new register() API**

```python
# append to tests/test_types.py


def test_register_with_handler(registry: TypeRegistry):
    class Money:
        pass

    class MoneyHandler(BaseTypeHandler):
        def deserialize(self, value):
            return Money()

    registry.register(Money, handler=MoneyHandler())
    registry.validate("price", Money, "Test")


def test_register_with_kwargs(registry: TypeRegistry):
    class Money:
        pass

    registry.register(Money, deserialize=lambda v: Money())
    registry.validate("price", Money, "Test")


def test_register_with_serialize_and_deserialize(registry: TypeRegistry):
    class Money:
        pass

    registry.register(
        Money,
        serialize=lambda v: {"amount": 0},
        deserialize=lambda v: Money(),
    )
    registry.validate("price", Money, "Test")


def test_register_serialize_only_raises(registry: TypeRegistry):
    class Money:
        pass

    with pytest.raises(ValueError, match="deserialize"):
        registry.register(Money, serialize=lambda v: v)


def test_register_handler_and_kwargs_exclusive(registry: TypeRegistry):
    class Money:
        pass

    with pytest.raises(ValueError, match="mutually exclusive"):
        registry.register(
            Money,
            handler=BaseTypeHandler(),
            deserialize=lambda v: v,
        )


def test_register_type_only_backwards_compat(registry: TypeRegistry):
    class MyType:
        pass

    registry.register(MyType)
    registry.validate("val", MyType, "Test")


def test_register_predicate_backwards_compat(registry: TypeRegistry):
    class Custom:
        __custom__ = True

    registry.register(lambda cls: hasattr(cls, "__custom__"))
    registry.validate("val", Custom, "Test")
```

- [ ] **Step 2: Refactor TypeRegistry.register()**

Update the `register` method signature and logic:

```python
def register(
    self,
    type_or_predicate: type | Callable[[type], bool],
    *,
    handler: TypeHandler | None = None,
    serialize: Callable[[Any], Any] | None = None,
    deserialize: Callable[[Any], Any] | None = None,
) -> None:
    """Register a type or predicate with an optional handler.

    Args:
        type_or_predicate: A type (exact match) or callable (predicate).
        handler: A TypeHandler instance. Mutually exclusive with kwargs.
        serialize: Serialize function. Requires deserialize.
        deserialize: Deserialize function.

    Raises:
        ValueError: If handler and kwargs are both provided, or serialize
                    without deserialize.
    """
    # Validate args
    if handler is not None and (serialize is not None or deserialize is not None):
        raise ValueError("handler= and serialize=/deserialize= are mutually exclusive")
    if serialize is not None and deserialize is None:
        raise ValueError("serialize= requires deserialize= (cannot serialize without deserialize)")

    # Build handler
    if handler is None and (serialize is not None or deserialize is not None):
        handler = _KwargsHandler(serialize_fn=serialize, deserialize_fn=deserialize)

    # Register
    if isinstance(type_or_predicate, type):
        self._scalar_types.add(type_or_predicate)
        if handler is not None:
            self._exact_handlers[type_or_predicate] = handler
    else:
        self._checkers.append(type_or_predicate)
        if handler is not None:
            self._predicate_handlers.append((type_or_predicate, handler))
```

Add `_exact_handlers` and `_predicate_handlers` to `__init__`:

```python
def __init__(self) -> None:
    self._scalar_types: set[type] = set(_SCALAR_TYPES)
    self._checkers: list[Callable[[type], bool]] = []
    self._exact_handlers: dict[type, TypeHandler] = {}
    self._predicate_handlers: list[tuple[Callable[[type], bool], TypeHandler]] = []
```

- [ ] **Step 3: Run tests, commit**

```bash
uv run pytest tests/test_types.py -v
git commit -am "feat: refactor TypeRegistry.register() with handler support"
```

---

### Task 3: Add get_handler() to TypeRegistry

**Files:**
- Modify: `src/cendry/types.py`
- Modify: `tests/test_types.py`

- [ ] **Step 1: Write failing tests**

```python
# append to tests/test_types.py


def test_get_handler_exact(registry: TypeRegistry):
    class Money:
        pass

    handler = BaseTypeHandler()
    registry.register(Money, handler=handler)
    assert registry.get_handler(Money) is handler


def test_get_handler_predicate(registry: TypeRegistry):
    class Base:
        pass

    class Child(Base):
        pass

    handler = BaseTypeHandler()
    registry.register(lambda cls: issubclass(cls, Base), handler=handler)
    # Need to also register for validation
    registry.register(Child)
    assert registry.get_handler(Child) is handler


def test_get_handler_none_for_builtin(registry: TypeRegistry):
    assert registry.get_handler(str) is None
    assert registry.get_handler(int) is None


def test_get_handler_none_for_unregistered(registry: TypeRegistry):
    class Unknown:
        pass

    assert registry.get_handler(Unknown) is None


def test_get_handler_kwargs(registry: TypeRegistry):
    class Money:
        pass

    registry.register(Money, deserialize=lambda v: v)
    handler = registry.get_handler(Money)
    assert handler is not None
    assert handler.deserialize(42) == 42
```

- [ ] **Step 2: Implement get_handler()**

```python
def get_handler(self, hint: type) -> TypeHandler | None:
    """Look up the handler for a type.

    Returns None for built-in types (handled internally).

    Lookup order:
    1. Exact type match
    2. First matching predicate (registration order)
    3. None
    """
    if hint in self._exact_handlers:
        return self._exact_handlers[hint]
    for predicate, handler in self._predicate_handlers:
        try:
            if predicate(hint):
                return handler
        except TypeError:
            continue
    return None
```

- [ ] **Step 3: Run tests, commit**

```bash
uv run pytest tests/test_types.py -v
git commit -am "feat: add get_handler() to TypeRegistry"
```

---

### Task 4: Update register_type module-level function

**Files:**
- Modify: `src/cendry/types.py`
- Modify: `tests/test_types.py`

- [ ] **Step 1: Write failing test**

```python
# append to tests/test_types.py
from cendry.types import register_type


def test_register_type_with_kwargs():
    class Currency:
        pass

    register_type(Currency, deserialize=lambda v: Currency())
    handler = default_registry.get_handler(Currency)
    assert handler is not None
```

- [ ] **Step 2: Update register_type signature**

```python
def register_type(
    type_or_predicate: type | Callable[[type], bool],
    *,
    handler: TypeHandler | None = None,
    serialize: Callable[[Any], Any] | None = None,
    deserialize: Callable[[Any], Any] | None = None,
) -> None:
    """Register a type handler in the global registry."""
    default_registry.register(
        type_or_predicate,
        handler=handler,
        serialize=serialize,
        deserialize=deserialize,
    )
```

- [ ] **Step 3: Run tests, commit**

```bash
uv run pytest tests/test_types.py -v
git commit -am "feat: update register_type with handler kwargs"
```

---

### Task 5: Integrate handlers with serialize.py

**Files:**
- Modify: `src/cendry/serialize.py`
- Create: `tests/test_handler_integration.py`

- [ ] **Step 1: Write integration tests**

```python
# tests/test_handler_integration.py
from unittest.mock import MagicMock

import pytest

from cendry import Cendry, Field, Model
from cendry import field as cendry_field
from cendry.serialize import deserialize, from_dict, to_dict
from cendry.types import BaseTypeHandler, TypeRegistry, default_registry
from tests.conftest import make_mock_document


class Money:
    def __init__(self, amount: int, currency: str) -> None:
        self.amount = amount
        self.currency = currency


class MoneyHandler(BaseTypeHandler):
    def serialize(self, value: Money) -> dict:
        return {"amount": value.amount, "currency": value.currency}

    def deserialize(self, value: dict) -> Money:
        return Money(amount=value["amount"], currency=value["currency"])


# Register globally for these tests
default_registry.register(Money, handler=MoneyHandler())


class Invoice(Model, collection="invoices"):
    title: Field[str]
    total: Field[Money]


def test_deserialize_with_handler():
    result = deserialize(Invoice, "inv1", {
        "title": "Test",
        "total": {"amount": 100, "currency": "USD"},
    })
    assert isinstance(result.total, Money)
    assert result.total.amount == 100
    assert result.total.currency == "USD"


def test_to_dict_with_handler():
    invoice = Invoice(title="Test", total=Money(amount=100, currency="USD"))
    result = to_dict(invoice)
    assert result["total"] == {"amount": 100, "currency": "USD"}


def test_from_dict_with_handler():
    invoice = from_dict(Invoice, {
        "title": "Test",
        "total": {"amount": 100, "currency": "USD"},
    })
    assert isinstance(invoice.total, Money)
    assert invoice.total.amount == 100


def test_context_get_with_handler(mock_firestore_client: MagicMock):
    doc = make_mock_document("inv1", {
        "title": "Test",
        "total": {"amount": 100, "currency": "USD"},
    })
    mock_firestore_client.collection.return_value.document.return_value.get.return_value = doc

    ctx = Cendry(client=mock_firestore_client)
    invoice = ctx.get(Invoice, "inv1")
    assert isinstance(invoice.total, Money)
    assert invoice.total.amount == 100
```

- [ ] **Step 2: Update _deserialize_value to check handlers**

In `serialize.py`, update `_deserialize_value` to consult the registry:

```python
def _resolve_inner_type(hint: Any) -> type | None:
    """Resolve a type hint to a concrete type, unwrapping Optional."""
    if hint is None or isinstance(hint, str):
        return None
    if isinstance(hint, types.UnionType):
        non_none = [a for a in get_args(hint) if a is not type(None)]
        if len(non_none) == 1:
            hint = non_none[0]
    if isinstance(hint, type):
        return hint
    return None


def _deserialize_value(value: Any, hint: Any) -> Any:
    """Deserialize a single value, checking handlers then Map nesting."""
    from .types import default_registry

    inner_type = _resolve_inner_type(hint)
    if inner_type is not None:
        handler = default_registry.get_handler(inner_type)
        if handler is not None:
            return handler.deserialize(value)
    if value is not None and isinstance(value, dict):
        inner = resolve_map_type(hint)
        if inner is not None:
            return deserialize_map(inner, value)
    return value
```

- [ ] **Step 3: Update _map_to_dict and to_dict to check handlers**

In `_map_to_dict`, after getting the value:

```python
def _map_to_dict(instance: Map, *, by_alias: bool) -> dict[str, Any]:
    from .types import default_registry

    hints = _cached_type_hints(type(instance))
    result: dict[str, Any] = {}
    for f in dataclasses.fields(instance):
        value = getattr(instance, f.name)
        key = _get_alias(f) if by_alias else f.name
        # Check handler first
        inner_type = _resolve_inner_type(hints.get(f.name))
        if inner_type is not None:
            handler = default_registry.get_handler(inner_type)
            if handler is not None:
                value = handler.serialize(value)
            elif isinstance(value, Map):
                value = _map_to_dict(value, by_alias=by_alias)
        elif isinstance(value, Map):  # pragma: no cover
            value = _map_to_dict(value, by_alias=by_alias)
        result[key] = value
    return result
```

- [ ] **Step 4: Run tests, commit**

```bash
uv run pytest tests/test_handler_integration.py -v
uv run pytest -q  # full suite
git commit -am "feat: integrate type handlers with serialization/deserialization"
```

---

### Task 6: Public API exports + CLAUDE.local.md

**Files:**
- Modify: `src/cendry/__init__.py`
- Modify: `tests/test_public_api.py`
- Modify: `CLAUDE.local.md`

- [ ] **Step 1: Update __init__.py**

Add to imports:
```python
from .types import BaseTypeHandler, TypeHandler
```

Add to `__all__`:
```python
"BaseTypeHandler",
"TypeHandler",
```

- [ ] **Step 2: Update test_public_api.py**

Add `"BaseTypeHandler"` and `"TypeHandler"` to the expected list.

- [ ] **Step 3: Update CLAUDE.local.md**

Add to Design Principles:

```markdown
- **Explicit type + handler registration** — `register_type(MyType, handler=MyHandler())` is the clean pattern. The handler doesn't know which type it handles — the registry maps types to handlers. Use kwargs (`serialize=`, `deserialize=`) for simple cases.
```

- [ ] **Step 4: Run full verification**

```bash
uv run ruff check src/ tests/
uv run ruff format --check src/ tests/
uv run mypy src/cendry/ tests/
uv run ty check src/cendry/ tests/
uv run coverage run -m pytest && uv run coverage report --show-missing
```

- [ ] **Step 5: Commit**

```bash
git commit -am "feat: export TypeHandler, BaseTypeHandler, update CLAUDE.local.md"
```
