← Back to Blog

Type checking a library whose product is types

·7 min read

pyRPC’s entire promise is that TypeScript types derived from Python survive the network boundary without drifting. Until last week, nothing verified the Python side of that promise statically: no mypy, no pyright, nothing. The TypeScript packages at least compiled through tsup builds, but no explicit gate failed when types broke. Both gaps are closed now, and both found real problems immediately.

The find: a transport lying about its contract

mypy’s first run over the five Python packages produced exactly thirteen errors in twenty-one files, and one of them was gold:

asgi.py:90: Argument 3 to "send_response" has incompatible type
  "dict[str, Any] | list[dict[str, Any]]"; expected "dict[str, Any]"

v0.13 introduced batched requests: handle_request accepts a list and returns one response per element. The ASGI transport passes the result straight to its serializer, which still declared dict-only. Python does not care (json.dumps is happy either way), so tests passed. But the annotation documented a contract that batching had silently broken. Widened to the union, with the comment trail pointing at the feature that changed it.

Fixing honestly instead of silencing

  • Lazy globals. cli.py declared default_router = None placeholders for deferred imports. Replaced with annotation-only declarations carrying real types; the runtime assignments fill them in.
  • The _cwd stash. The dev server process carried its working directory as a dynamic attribute (proc._cwd = ...) for config-triggered restarts. mypy rightly refused. Now there is a tiny _ServerProcess(Popen[bytes]) subclass with a typed cwd, and the console reads it defensively for externally attached processes.
  • Invariants mypy cannot see. After the config block in dev(), spec and cfg_path are guaranteed non-None by construction. Two asserts with comments now state that invariant where the type system needs it. Asserts are runtime-checked honesty, not suppression.
  • Untyped third parties (jsonc_edit, jsonschema_ts, django) get per-module ignore_missing_imports overrides rather than a global blindfold.

The TypeScript side

Each of the six npm packages gained a typecheck script running tsc --noEmit, aggregated by a root script and executed in CI after builds. All six passed on day one, which tracks with strict mode already being on; the value is that this stays true automatically. tsup emitting declarations during builds had been quietly serving as a weak proxy for this gate, and proxies erode.

Scope, stated plainly

Sources are covered; tests are not yet. That is a deliberate line drawn so the first gate ships fast and meaningful rather than slow and noisy. It is written down in CONTRIBUTING.md as a follow-up, because a scope you do not publish is a scope people assume is infinite.

For a framework whose debugging story begins with the phrase the types are generated from your Python signatures, checking our own types was overdue by exactly one shipped contract drift.