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 concreteRe-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 resultunsafe: 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: intclass 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:
class User: id: int name: str email: str
class UserNotFoundError(Exception): user_id: int
class NetworkError(Exception): pass
def fetch_user(id: int) -> UserNow 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 raiseslet admin: User = fetch_user(0).expect("admin user must exist") # raise on Errif 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 stubtestWhen 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
- The Unsafe Boundary —
unsafe:mechanics. - Writing .dty Stubs — stub format.
- Stub Drift — keeping in sync.