Typing services and selectors¶
The framework wires a service or selector to a view by inspecting its signature at request time and passing only the parameters it declares from a known kwargs pool:
| Action | Pool keys |
|---|---|
| Create | request, user, data*, plus extras |
| Update / partial update | request, user, instance, data*, plus extras |
| Destroy | request, user, instance, data*, plus extras |
| List selector | request, user, URL kwargs, plus extras |
| Retrieve selector | request, user, URL kwargs, plus extras |
| Output selector (nested) | request, user, result, the action's pool, extras |
* data is only present when ServiceSpec.input_serializer is set.
view is intentionally not in the pool. Services and selectors should
be plain business logic; pipe view state through a kwargs provider instead
(see below).
What the framework guarantees statically¶
ServiceSpec and SelectorSpec are generic over the input / result type
and an optional TypedDict describing extra kwargs:
class ServiceSpec(Generic[InputT, ResultT, ExtraT]): ...
class SelectorSpec(Generic[ResultT, ExtraT]): ...
Without parameterization, ServiceSpec(service=fn) keeps working — the
generic params default to Any, exactly as before.
Parameterizing them lets type checkers connect the input serializer, the service signature, and the output:
spec: ServiceSpec[AuthorIn, Author] = ServiceSpec(
service=create_author,
input_serializer=AuthorIn,
)
Unified service / selector Protocols¶
Each Protocol is parameterised on input / instance / result types only.
**extras is typed Any so the framework's kwargs pool flows through —
services declare only the parameters they read:
from rest_framework_services import CreateService
def create_author(
*,
data: AuthorIn,
**extras, # request, user, tenant_id, ... — not enforced
) -> Author: ...
# Static check that the function matches the Protocol shape:
_check: CreateService[AuthorIn, Author] = create_author
For strict-typed extras — when you want the type checker to assist on
extras["tenant_id"] accesses — declare a TypedDict and unpack it into
your function via PEP 692
Unpack[TypedDict]. The typing lives on the function signature, not on
the Protocol; pair with the
@implements
decorator to attach the structural assertion at the def site:
from typing_extensions import TypedDict, Unpack
from rest_framework_services import CreateService, implements
class CreateAuthorKwargs(TypedDict, total=False):
tenant_id: int
@implements(CreateService[AuthorIn, Author])
def create_author(
*,
data: AuthorIn,
**extras: Unpack[CreateAuthorKwargs],
) -> Author:
tenant_id = extras.get("tenant_id") # typed as int | None
...
total=False (or per-field NotRequired) is required so the function
stays Protocol-conformant — under PEP 692, any required key would make
the function reject callers that omit it, breaking the structural check
against CreateService / ListSelector / etc.
Drift between create_author and the parameterised Protocol produces a
ty / pyright error at the @implements(...) line. Drift inside the
function body (e.g. typo'd extras["tenent_id"]) is caught by the
TypedDict's normal key check.
The Protocols deliberately do not name request or user in their
fixed signature. The framework still puts both keys in the kwargs pool —
services that read them either pick them up off **extras directly, or
unpack a HttpExtras[YourUser] subclass (a total=False TypedDict)
for typed access:
from rest_framework_services import CreateService, HttpExtras, implements
from typing_extensions import Unpack
class CreateAuthorKwargs(HttpExtras[MyUser], total=False):
tenant_id: int
@implements(CreateService[AuthorIn, Author])
def create_author(
*,
data: AuthorIn,
**extras: Unpack[CreateAuthorKwargs],
) -> Author:
user = extras.get("user") # typed as MyUser | None
request = extras.get("request") # typed as Request | None
...
Available Protocols:
CreateService[InputT, ResultT]UpdateService[InputT, InstanceT, ResultT]DeleteService[InputT, InstanceT, ResultT]— bindInputTto your input dataclass for delete-with-payload, or toNoInputwhen no body is read.ListSelector[ResultT]RetrieveSelector[ResultT]— also used for the post-mutation re-fetch selector mounted underServiceSpec.output_selector_spec.selector, where the kwargs pool additionally carriesresult.
Migrating from Strict* / 3-arg parameterisation (0.6 – 0.10)
Releases 0.6 – 0.10 shipped separate StrictCreateService /
StrictListSelector / … Protocols that carried an explicit
ExtraT TypedDict as a type argument. 0.11 removes those
classes and folds them into the unified Protocols above, with
the kwargs-shape moved off the Protocol and onto the function
signature instead. The cross-Protocol enforcement of ExtraT
only ever worked under one minor version of ty (0.0.32) — mypy
and pyright always rejected the Unpack[<TypeVar>] pattern.
Strict-typed extras now live on your function via
**extras: Unpack[YourKw], which type-checks consistently on
every modern checker.
ServiceSpec and SelectorSpec themselves still carry their
trailing ExtraT generic parameter — that part of the API is
unchanged. The migration removes ExtraT only from the service
and selector Protocols.
Migration: rename Strict* to the unified name, drop the trailing
ExtraT from every parameterisation site, and ensure your extras
TypedDict declares its keys as NotRequired / total=False:
# Before (0.10)
@implements(StrictCreateService[AuthorIn, MyKw, Author])
def create_author(*, data: AuthorIn, **extras: Unpack[MyKw]) -> Author: ...
# After (0.11)
class MyKw(TypedDict, total=False):
tenant_id: int
@implements(CreateService[AuthorIn, Author])
def create_author(*, data: AuthorIn, **extras: Unpack[MyKw]) -> Author: ...
There is no deprecation bridge.
Per-spec kwargs providers — drop the if self.action == ... chain¶
A viewset with several actions whose services take different extras has
historically had only one hook (get_service_kwargs), forcing branches like
if self.action == "create": .... Move the contract onto the spec instead:
from rest_framework_services import ServiceSpec, ServiceViewSet
from typing import TypedDict
class CreateAuthorKwargs(TypedDict):
tenant_id: int
def _create_author_kwargs(view: ServiceView, request: Request) -> CreateAuthorKwargs:
return {"tenant_id": request.tenant.id}
class AuthorViewSet(ServiceViewSet):
action_specs = {
"create": ServiceSpec(
service=create_author,
input_serializer=AuthorIn,
kwargs=_create_author_kwargs, # per-spec, typed
),
"publish": ServiceSpec(
service=publish_author,
kwargs=_publish_author_kwargs, # different TypedDict
),
}
The kwargs callable receives the view (typed as the narrow ServiceView
Protocol — exposes request, kwargs, action) and the current request,
and returns the TypedDict it declares. Services and selectors stay
business-logic-pure; view is only available where it makes sense — at
the framework-glue boundary.
Resolution order¶
When the framework needs to assemble extras for a call, it merges three layers (later overrides earlier — most specific wins):
- Catch-all hook —
view.get_service_kwargs(self)/view.get_selector_kwargs(self). The existing global fallback. - Per-action hook —
view.get_<action>_service_kwargs(self)/view.get_<action>_selector_kwargs(self). Discoverable in IDEs; noif self.action == ...needed. - Per-spec callable —
ServiceSpec.kwargs/SelectorSpec.kwargs. The most specific level; co-located with the service it feeds.
Use whichever level matches the granularity of your contract.
Attaching the Protocol to the function: @implements¶
The recommended way to assert that a callable matches a strict Protocol is
the implements
decorator — it returns the function unchanged at runtime and triggers the
structural-subtyping check at the decorator line:
@implements(ListSelector[Author])
def list_authors(
**extras: Unpack[ListAuthorsKwargs],
) -> Iterable[Author]: ...
The legacy throwaway-variable form still works and is sometimes useful for ad-hoc one-off checks:
A few notes on type-checker support:
tyvalidates@implements(...)against the parameterized Protocols; the decorator is the form CI exercises in this repo.mypyrejectstype[Protocol]arguments under itstype-abstractrule, so@implements(...)triggers a[type-abstract]error in mypy. Either add# type: ignore[type-abstract]next to the decorator or stick with the_check: ...shim form when mypy is your primary checker.- PEP 692 support across checkers is uneven — drift detection on
**extras: Unpack[TypedDict]works best when the function uses theUnpack[...]form (matching the Protocol). Strict parameterization still catches drift on the fixed pool keys (data,instance, return type) in every supported checker.
Fail-fast validation at as_view()¶
as_view() walks every spec and validates that the callable's signature
can be satisfied. Misconfigured specs raise
ImproperlyConfigured
at URL-wiring time instead of with a generic TypeError at the first
request:
- Service requires
databut the spec has noinput_serializer. - Service requires
instancebut the action is create / list (no instance). - The nested
output_selector_spec.selectorrequires a key that's only present in service-call context. - A mounted
SelectorSpec'skinddisagrees with the view it's attached to — e.g. aSelectorKind.LISTspec onSelectorRetrieveView, or aSelectorKind.RETRIEVEentry underaction_specs["list"]. - A
ServiceSpec.output_selector_specis supplied with akindother thanSelectorKind.RETRIEVE(the post-mutation re-fetch always materializes a single instance). - A required parameter is not framework-provided and the view has no
kwargs=provider, noget_<action>_*_kwargs, and noget_*_kwargs— the parameter would be silently dropped at request time.
The validator is permissive when the user has plugged in an extras source (the framework can't statically introspect what those provide). Strict parameterization (above) covers that gap on the static side.