Async and Concurrency
Typhon’s async story is explicit, not inferred. A function is async because you said so, not because the compiler decided. On top of that, two ergonomic features — gather: and go — make concurrent code shorter without sacrificing safety.
Why explicit?
Inferring async (the way some languages do) means a function’s “colour” can change when a deep callee changes — refactoring becomes fragile, and stack traces become confusing. Typhon stays with the explicit-async rule from Python, and adds two compile-time checks:
- An
asyncfunction that contains noawaitis a warning — probably a mistake. - A sync function that calls an
asyncone withoutawaitis a hard error — definitely a mistake.
async def fetch(url: str) -> str: ...
def main() -> None: let body: str = fetch("https://example.com") # ❌ coroutine, not strerror[tyc::missing_await]: cannot use a coroutine where `str` is required ┌─ src/main.ty:5:21 │5 │ let body: str = fetch("https://example.com") │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ did you mean `await`? and is `main` declared `async`?Basic async
import asyncio
async def fetch(url: str) -> str: # imagine some I/O here await asyncio.sleep(0.1) return f"body of {url}"
async def main() -> None: let body: str = await fetch("https://example.com") print(body)
if __name__ == "__main__": asyncio.run(main())Everything else from the language — let/mut, Result, narrowing — works inside async def unchanged.
gather: — run independent awaits in parallel
A common shape: several independent network calls, each behind an await. Sequentially awaiting them is slow when they don’t depend on each other.
# Sequential — 300ms if each call takes 100msasync def load_dashboard(user_id: int) -> Dashboard: let user: User = await fetch_user(user_id) let posts: list[Post] = await fetch_posts(user_id) let notifs: list[Notif] = await fetch_notifs(user_id) return Dashboard(user=user, posts=posts, notifs=notifs)gather: rewrites that as a parallel block:
# Parallel — ~100msasync def load_dashboard(user_id: int) -> Dashboard: gather: user = fetch_user(user_id) posts = fetch_posts(user_id) notifs = fetch_notifs(user_id) return Dashboard(user=user, posts=posts, notifs=notifs)async with asyncio.TaskGroup() as _tg: _t_user = _tg.create_task(fetch_user(user_id)) _t_posts = _tg.create_task(fetch_posts(user_id)) _t_notifs = _tg.create_task(fetch_notifs(user_id))user = _t_user.result()posts = _t_posts.result()notifs = _t_notifs.result()Each binding inside gather: is awaited in parallel. After the block, the names are in scope as their resolved values.
How gather: desugars
By default, gather: lowers to asyncio.TaskGroup:
TaskGroup is the right default: if any task fails, the siblings are cancelled and the exception propagates. That’s what you want for side-effectful work — you don’t want one failed payment leaving its siblings to continue.
Opting into best-effort semantics
If you genuinely want partial success (e.g. fanning out reads where one missing piece is fine), use gather(strategy="best-effort"):, which lowers to asyncio.gather(..., return_exceptions=True):
gather(strategy="best-effort"): user = fetch_user(user_id) posts = fetch_posts(user_id) notifs = fetch_notifs(user_id)In this mode, each bound name is T | Exception (or similar) — the failed ones come back as exception objects you can inspect. The checker reflects that in the types.
Automatic asyncio.gather (opt-in)
The analyser can rewrite sequential awaits as gather automatically, but only when:
- Every called function is a same-module
async defcarrying an explicit@gatherabledecorator. - LHS bindings don’t alias.
- The statements form a straight-line block.
This is opt-in via project config — it isn’t applied silently:
[strictness]auto-gather = trueAuto-gather now reaches across module boundaries too: if the callees are @gatherable async functions imported from another project module, a run of independent awaits folds into one TaskGroup just like a same-module run. (The decorator is still required on every callee.)
Most teams pick explicit gather: blocks instead, because they make intent visible in the source.
go — fire-and-forget tasks
Sometimes you want to spawn work without awaiting it: a background email, a metrics flush, a write-behind cache. go is that primitive:
async def signup(email: str) -> User: let user: User = await create_user(email) go send_welcome_email(user) # fire and forget return usergo lowers through typhon_runtime.tasks.spawn, which keeps a strong reference to the task in a registry. This matters: Python’s event loop holds only weak references to tasks, so a bare asyncio.create_task(...) whose handle is dropped can be garbage-collected mid-flight. The Typhon runtime helper prevents that.
Capturing the handle
If you want to join later, bind the task:
async def signup(email: str) -> User: let user: User = await create_user(email) go send_welcome_email(user) -> email_task # ... later ... await email_task return userThe type of email_task matches the underlying spawn primitive (an asyncio.Task in async contexts, a Future on free-threaded builds for CPU-bound work).
Free-threaded Python (3.13t / 3.14t)
Set free-threaded = true in typhon.toml to opt into emit paths that use threads (not just async tasks) for parallelism:
[python]target = "3.14"free-threaded = trueWhen this is on:
goalways lowers throughtyphon_runtime.tasks.spawn(anasynciotask held in a strong-ref registry) — there is noThreadPoolExecutorlowering forgo, including on free-threaded builds.- The analyser may emit
ThreadPoolExecutor.map(...)for pure-function comprehensions over large collections. - Every emitted parallel block first checks
sys._is_gil_enabled()at runtime and falls back to sequential execution if a GIL build is detected.
This is default-off until 3.14 ships as the default Python.
Why the runtime fallback?
Free-threaded mode requires a special CPython build. If your wheel is run on a stock GIL Python, the threading parallelism would serialise anyway — worse, it might trigger race-related bugs the GIL had been hiding. The fallback keeps the code correct on any 3.13+/3.14+ interpreter.
await inside loops
A common shape that isn’t a fit for gather:
async def process(urls: list[str]) -> list[str]: mut results: list[str] = [] for url in urls: let body: str = await fetch(url) results.append(body) return resultsThe await is sequential and order-dependent — gather: won’t help here. If you want concurrent processing of a list, do it explicitly with asyncio.gather(*[...]) (still available) or a TaskGroup over an iterable.
Putting it together
A small example: fetch user data from three independent endpoints in parallel, spawn a fire-and-forget metrics call, and surface a typed error if any fetch fails.
import asyncio
class User: id: int name: strclass Post: id: int title: strclass Notif: id: int text: str
type LoadError = NotFound | Timeoutclass NotFound: what: strclass Timeout: after_ms: int
async def fetch_user(id: int) -> Result[User, LoadError]: ...async def fetch_posts(id: int) -> Result[list[Post], LoadError]: ...async def fetch_notifs(id: int) -> Result[list[Notif], LoadError]: ...async def record_load(id: int, ms: int) -> None: ...
class Dashboard: user: User posts: list[Post] notifs: list[Notif]
async def load_dashboard(user_id: int) -> Result[Dashboard, LoadError]: let started: float = asyncio.get_event_loop().time()
gather: user_r = fetch_user(user_id) posts_r = fetch_posts(user_id) notifs_r = fetch_notifs(user_id)
let user: User = user_r? let posts: list[Post] = posts_r? let notifs: list[Notif] = notifs_r?
let elapsed_ms: int = int((asyncio.get_event_loop().time() - started) * 1000) go record_load(user_id, elapsed_ms)
return Ok(Dashboard(user=user, posts=posts, notifs=notifs))from __future__ import annotationsfrom typhon_runtime import Ok, Err, Resultimport typhon_runtimeimport dataclassesfrom typhon_runtime import Err as __typhon_Err__import asyncio
# ... class declarations elided — each becomes @dataclasses.dataclass(slots=True) ...
async def load_dashboard(user_id: int) -> Result[Dashboard, LoadError]: started: float = asyncio.get_event_loop().time() async with asyncio.TaskGroup() as __typhon_tg_0__: __typhon_gather_1__ = __typhon_tg_0__.create_task(fetch_user(user_id)) __typhon_gather_2__ = __typhon_tg_0__.create_task(fetch_posts(user_id)) __typhon_gather_3__ = __typhon_tg_0__.create_task(fetch_notifs(user_id)) user_r = __typhon_gather_1__.result() posts_r = __typhon_gather_2__.result() notifs_r = __typhon_gather_3__.result() __typhon_q_0__ = user_r if isinstance(__typhon_q_0__, __typhon_Err__): return __typhon_q_0__ user: User = __typhon_q_0__.value __typhon_q_1__ = posts_r if isinstance(__typhon_q_1__, __typhon_Err__): return __typhon_q_1__ posts: list[Post] = __typhon_q_1__.value __typhon_q_2__ = notifs_r if isinstance(__typhon_q_2__, __typhon_Err__): return __typhon_q_2__ notifs: list[Notif] = __typhon_q_2__.value elapsed_ms: int = int((asyncio.get_event_loop().time() - started) * 1000) typhon_runtime.tasks.spawn(record_load(user_id, elapsed_ms)) return Ok(Dashboard(user=user, posts=posts, notifs=notifs))Three Typhon-only lowerings are all visible here: gather: wraps the three coroutines in an asyncio.TaskGroup and pulls their results out via .result(), each ? becomes a typhon_q temp + isinstance(_, Err) early-return, and go calls into typhon_runtime.tasks.spawn (the strong-ref registry — never bare asyncio.create_task).
Walk-through:
- The three
fetch_*calls run in parallel insidegather:— total latency ismax, notsum. - Each binding is still
Result[...]. The?operator unwraps them after the gather; if any returnedErr, this function returns that error. record_loadis fire-and-forget viago. The metrics call doesn’t block the response.- The whole thing is
asyncand returns aResult, so error handling and async compose without ceremony.
Common mistakes
Forgetting await
async def main() -> None: let body: str = fetch("...") # ❌ missing awaittyc check flags this. Add await.
async function with no await
async def helper() -> int: return 42warning[tyc::async_without_await]: `helper` is `async` but never awaits; make it sync, or `await` something inside itEither drop async or actually await something. Don’t suppress this — it’s almost always wrong.
Using asyncio.create_task directly for fire-and-forget
asyncio.create_task(send_welcome_email(user)) # ⚠️ task may be GC'd before completionFix: go send_welcome_email(user) — the runtime registry holds a strong reference.
Putting blocking I/O inside an async function
async def load() -> bytes: with open("data.bin", "rb") as f: return f.read() # blocks the event loopTyphon doesn’t catch this (it’s a Python-wide hazard). Use aiofiles or asyncio.to_thread(...) for blocking calls in async code.
What you’ve learned
asyncis explicit; missingawaitis a hard error, redundantasyncis a warning.gather:blocks run independent awaits in parallel, defaulting toTaskGroup(cancel-on-failure).gather(strategy="best-effort"):switches toasyncio.gather(..., return_exceptions=True).go f(x)spawns fire-and-forget work safely via a strong-ref registry.- Free-threaded mode opens threading-based parallelism on 3.13t/3.14t with a runtime fallback to sequential.
Where next
- Advanced Features — pipes,
comptime, lazy imports, purity, and theunsafeboundary. async,await,gather,go— the reference page.gather:→ TaskGroup — exactly what is emitted.go→ spawn registry — how the runtime registry works.