# Type Handler System Design Spec

## Overview

Replace the validation-only `register_type` with a richer type handler system that bundles validation, serialization, and deserialization — extensible for future behaviors.

Currently `register_type` only answers "is this type valid?" This spec adds "how do I convert this type to/from Firestore?"

## Design Principles

- **Handlers are explicit** — `register_type(MyType, handler=MyHandler())` or `register_type(MyType, deserialize=fn)`. The handler doesn't need to know which type it handles — the registry maps types to handlers.
- **Kwargs for simple cases** — consistent with `field(alias=..., enum_by=...)`. A user who just needs a deserializer shouldn't define a class.
- **`deserialize` is required** — the library reads from Firestore, so you must tell it how to reconstruct your type. `serialize` is optional (defaults to identity) since writes aren't implemented yet.
- **Built-in types use internal handlers** — scalars, containers, Maps, enums all have internal handlers (identity passthrough, since the Firestore SDK handles conversion). Users cannot override built-in types.
- **Extensible** — new methods can be added to `BaseTypeHandler` with defaults. Existing handlers don't break.

## TypeHandler Protocol and BaseTypeHandler

```python
from typing import Any, Protocol

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 here in the future without breaking existing handlers.
    """

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

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

- `TypeHandler` is a Protocol — any class implementing these methods works.
- `BaseTypeHandler` provides identity defaults.
- `validate()` is gone from the handler — registration *is* validation. If you register it, it's valid.

## Registration API

### Simple — kwargs shortcut

```python
from cendry import register_type

# Both directions
register_type(Money,
    serialize=lambda v: {"amount": v.amount, "currency": v.currency},
    deserialize=lambda v: Money(amount=v["amount"], currency=v["currency"]),
)

# Deserialize only — serialize defaults to identity
register_type(Money,
    deserialize=lambda v: Money(amount=v["amount"], currency=v["currency"]),
)
```

### Full handler class

```python
from cendry import BaseTypeHandler, register_type

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

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

register_type(Money, handler=MoneyHandler())
```

### Predicate-based — for families of types

```python
# With handler
register_type(lambda cls: issubclass(cls, SomeBase), handler=SomeBaseHandler())

# With kwargs
register_type(
    lambda cls: issubclass(cls, SomeBase),
    deserialize=lambda v: SomeBase.from_raw(v),
)
```

### Type-only (backwards compatible)

```python
# Still works — type is valid, serialize/deserialize are identity
register_type(MyCustomClass)
register_type(lambda cls: hasattr(cls, "__custom__"))
```

### Rules

- First argument is either a `type` (exact match) or a `callable` (predicate).
- `handler=` and kwargs (`serialize=`, `deserialize=`) are mutually exclusive.
- `deserialize` is required when providing `serialize` (you can't serialize without knowing how to deserialize). `serialize` alone without `deserialize` raises `ValueError`.
- When neither `handler=` nor kwargs are provided, a `BaseTypeHandler()` (identity) is used.
- When `serialize` is not provided but `deserialize` is, `serialize` defaults to identity. When writes are added later, the library will raise at write-time if a real `serialize` is needed.

## TypeRegistry Changes

### Current API (preserved)

```python
class TypeRegistry:
    def register(self, type_or_predicate): ...
    def validate(self, field_name, hint, class_name): ...
```

### New API

```python
class TypeRegistry:
    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: ...

    def validate(self, field_name: str, hint: Any, class_name: str) -> None: ...

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

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

### Internal storage

The registry stores:
- `_exact_handlers: dict[type, TypeHandler]` — exact type → handler
- `_predicate_handlers: list[tuple[Callable, TypeHandler]]` — predicate + handler pairs
- `_scalar_types`, `_checkers` — existing validation data (unchanged)

### Lookup order for `get_handler(hint)`

Predicates are evaluated in registration order — first match wins.

1. Exact type match in `_exact_handlers`
2. First matching predicate in `_predicate_handlers`
3. `None` — built-in type, no custom handler

### Kwargs → handler conversion

When `serialize=` or `deserialize=` kwargs are provided, the registry creates a `BaseTypeHandler` subclass internally:

```python
class _KwargsHandler(BaseTypeHandler):
    def __init__(self, serialize_fn, deserialize_fn):
        self._serialize = serialize_fn
        self._deserialize = deserialize_fn

    def serialize(self, value):
        return self._serialize(value)

    def deserialize(self, value):
        return self._deserialize(value)
```

## Integration with serialize.py

### deserialize / deserialize_map

For each field, after resolving the type hint:

```python
# Pseudocode for field deserialization
handler = registry.get_handler(field_type)
if handler is not None:
    value = handler.deserialize(value)
elif is_map_subclass(field_type):
    value = deserialize_map(field_type, value)
# else: passthrough (built-in scalar)
```

### Container types

For `list[Money]`, `set[Money]`, `dict[str, Money]`: the existing recursive container logic unwraps inner types. The handler is applied to each element's inner type, not the container itself. For example, `list[Money]` → each element is deserialized via `MoneyHandler().deserialize(element)`.

### to_dict / _map_to_dict

For each field value:

```python
handler = registry.get_handler(field_type)
if handler is not None:
    value = handler.serialize(value)
elif is_map_instance(value):
    value = _map_to_dict(value, by_alias=by_alias)
# else: passthrough
```

### from_dict

Same handler logic as `deserialize`, respecting `by_alias`.

### Handler resolution

The handler is resolved per field using the cached type hints. The registry lookup is a dict lookup (O(1) for exact types) or a linear scan of predicates (rare, for advanced use).

## Module-level `register_type` function

The existing `register_type` function delegates to `default_registry.register()` with the new 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,
    )
```

## File Changes

| File | Change |
|---|---|
| `src/cendry/types.py` | Add `TypeHandler` Protocol, `BaseTypeHandler`, `_KwargsHandler`. Refactor `TypeRegistry.register()` with new signature, add `get_handler()`. |
| `src/cendry/serialize.py` | `deserialize`, `deserialize_map`, `to_dict`, `_map_to_dict` check `registry.get_handler()` before falling through to existing logic. |
| `src/cendry/__init__.py` | Export `TypeHandler`, `BaseTypeHandler`. |
| `CLAUDE.local.md` | Document handler registration design rule. |

## Public API Additions

- `TypeHandler` — Protocol
- `BaseTypeHandler` — base class with defaults

## Backwards Compatibility

- `register_type(MyType)` still works — creates identity handler
- `register_type(lambda cls: ...)` still works — creates identity handler
- `TypeRegistry.validate()` unchanged
- Existing tests pass without modification
