Skip to content

Writing .dty Stubs

A .dty stub describes a Python module’s API in Typhon’s strict dialect (T?, Result[T, E], sealed unions, interfaces). The compiler:

  • Checks your code against the stub at build time.
  • Emits a .pyi companion so mypy / pyright / Pyrefly / ty also understand it.

File location

Place .dty files inside your configured src/ directory (commonly src/stubs/<module>.dty). tyc build and tyc check --stubs pick up every .dty under src/; files outside that directory are not scanned.

Example

src/stubs/redis.dty
class Redis:
host: str
port: int
impl Redis:
def get(self, key: str) -> str?
def set(self, key: str, value: str) -> bool
def delete(self, *keys: str) -> int
def keys(self, pattern: str) -> list[str]
def from_url(url: str) -> Redis

Notes:

  • The same syntax as .ty, but methods have no body (signatures only).
  • T? is T | None in the emitted .pyi.
  • Class fields work — the stub asserts their types.
  • Free functions sit at module level.

What emits as .pyi

Standard PEP 561 stub. Method bodies become ....

Stubbing a third-party module

If you’re stubbing a module you don’t own (e.g. redis-py):

  1. Write src/stubs/redis.dty.
  2. Run tyc check --stubs to confirm the stub matches the runtime module’s surface.
  3. Update the stub when the library upgrades and tyc check --stubs complains.

Stubbing a Typhon-authored library for Python consumers

If you’re writing a .ty library that Python users will consume via the emitted .py:

  1. The .py carries Typhon’s annotations as standard Python annotations (T | None, etc.).
  2. You usually don’t need a separate .dty — the emitted .py is typed already.

You’d write a .dty only if you want to expose a stricter API surface in Typhon than the implementation reveals (e.g. hiding an internal helper).

Compiler-bundled stubs (Layer 0)

You don’t always have to write the stub yourself. tyc ships curated, embedded .dty stubs for popular libraries whose packaging defeats venv introspection — httpx and requests to start. tyc check, tyc build, and the LSP seed these into the project shape map before venv introspection runs, so the library is shaped out of the box:

  • Its construction is type-checked.
  • Its unintrospectable-dependency warning is suppressed.
  • No .venv and no tyc sync are required.

Authored stubs win

Bundled stubs are gap-fill only. The precedence is: an authored project .dty/.ty for the same module beats the bundle, and the bundle in turn beats venv introspection. So if you write src/stubs/httpx.dty, your stub is the surface the checker uses — the bundle steps aside. Write your own for long-lived dependencies you call a lot and want pinned to a precise, strict surface.

partial leniency

Bundled class shapes are marked partial: members the stub omits stay lenient, so a real attribute the curated stub didn’t enumerate won’t raise a false attribute_not_found. Request methods take **kwargs: object, and client constructors enumerate the common kwargs as optional fields. The aim is to shape the head of the dependency distribution without becoming a maintenance burden; the long tail stays best-effort.

Common patterns

Optional return that’s also nullable

src/stubs/db.dty
impl Database:
def find_user(self, id: int) -> User? # may be None

Generic methods

src/stubs/cache.dty
class Cache[K, V]:
def get(self, key: K) -> V?
def put(self, key: K, value: V) -> None

Class hierarchies

src/stubs/orm.dty
class Model:
id: int
class User(Model):
name: str

Async functions

impl HttpClient:
async def get(self, url: str) -> Response
async def post(self, url: str, body: bytes) -> Response

What’s not stubbed

.dty is for typed surfaces. If the module is genuinely dynamic (returns Any everywhere, uses **kwargs extensively), you may need to:

  • Use unsafe: blocks at the call site.
  • Wrap the dynamism in a small typed adapter.

You don’t have to stub everything — only what your project consumes.

Where next