Skip to content

Wrapping an Untyped Library

A workflow for taming a third-party library’s dynamic surface: start with unsafe:, write a small typed adapter, promote to a .dty stub when the surface stabilises.

Day 0 — unsafe: exploration

import some_untyped_lib
def fetch_user_raw(id: int) -> dict[str, str]:
unsafe:
let raw = some_untyped_lib.fetch_user(id)
return raw # ❌ would fail boundary — Unsafe[Any] can't flow into concrete

Re-assert at the boundary:

def fetch_user_raw(id: int) -> dict[str, str]:
unsafe:
let raw = some_untyped_lib.fetch_user(id)
let result: dict[str, str] = raw # re-assertion via annotation
return result

unsafe: lets you talk to the library. The annotation at the boundary is your “I claim this is the shape” assertion.

Day 1 — Typed adapter

Wrap each call in a small typed function. Inside it, unsafe: does the call. Outside, callers see a typed signature:

import some_untyped_lib
class User:
id: int
name: str
email: str
type UserError = NotFound | NetworkError
class NotFound:
id: int
class NetworkError:
detail: str
def fetch_user(id: int) -> Result[User, UserError]:
unsafe:
try:
let raw = some_untyped_lib.fetch_user(id)
except some_untyped_lib.UserNotFoundError:
return Err(NotFound(id=id))
except some_untyped_lib.NetworkError as e:
return Err(NetworkError(detail=str(e)))
let id_int: int = int(raw["id"])
let name: str = str(raw["name"])
let email: str = str(raw["email"])
return Ok(User(id=id_int, name=name, email=email))

Now downstream callers consume fetch_user’s typed signature. The dynamic-typing exposure is confined to the adapter.

Day N — .dty stub

When the library’s API has stabilised in your usage, write a .dty stub:

src/stubs/some_untyped_lib.dty
class User:
id: int
name: str
email: str
class UserNotFoundError(Exception):
user_id: int
class NetworkError(Exception):
pass
def fetch_user(id: int) -> User

Now import some_untyped_lib gives you some_untyped_lib.fetch_user(id) -> User directly. The adapter shrinks to:

def fetch_user(id: int) -> Result[User, UserError]:
try:
return Ok(some_untyped_lib.fetch_user(id))
except some_untyped_lib.UserNotFoundError:
return Err(NotFound(id=id))
except some_untyped_lib.NetworkError as e:
return Err(NetworkError(detail=str(e)))

No more unsafe:. The dynamism crosses through the stub, not through your code.

One-expression boundary bridge — try_result

When the boundary is a single call whose exception you want as one Err — rather than distinct exception types mapped to distinct variants — try_result (v0.15.0) collapses the whole try/except shim into one expression. It is a prelude name (no import needed in source), and tyc build auto-injects from typhon_runtime import try_result:

def fetch_user(id: int) -> Result[User, str]:
return try_result(
lambda: some_untyped_lib.fetch_user(id),
lambda e: f"fetch failed: {e}",
)

It runs the thunk and returns Ok(result); on any exception it returns Err(on_err(exc)). Omit the mapper to carry the raw exception as Result[T, Exception]. The result is a genuine, typed Result — T inferred from the thunk, E from the mapper — so a wrong return annotation still fires tyc::type_mismatch.

Keep the explicit multi-except shim above when you map distinct exception types to distinct error variants (UserNotFoundError → NotFound, NetworkError → NetworkError); reach for try_result for the common single-boundary case. See try_result for the full rules.

Escaping Result deliberately

Once the typed adapter hands you a Result, most call sites propagate it with ?. Where you genuinely want to leave the Result world — a script that should fall back to a default, or a startup path where failure is fatal — the unwrap/query family is the escape hatch:

let user: User = fetch_user(id).unwrap_or(GUEST) # default on Err, never raises
let admin: User = fetch_user(0).expect("admin user must exist") # raise on Err
if fetch_user(id).is_ok():
...

Drift detection

Run tyc check --stubs and tyc stubtest in CI. When the library upgrades and your stub falls behind, the drift surfaces immediately.

- name: Stub drift
run: |
tyc check --stubs
tyc stubtest

When to skip the stub

For one-off scripts or genuinely-dynamic libraries (RPC stubs, eval heavy), keep the adapter pattern. Don’t write stubs that you’d have to constantly update.

Where next