Advanced Features
The features in this page are the ones you’ll reach for once Typhon is part of your everyday workflow: composing transformations with pipes, computing values at build time with comptime, deferring expensive imports with lazy, opting into caching with @pure/@memo, and crossing the boundary into untyped Python with unsafe and .dty stubs.
Pipes
A pipe (|>) takes the value on its left and threads it into the first positional slot of the call on its right:
let raw: str = " Hello, World! "let cleaned: str = raw |> str.strip() |> str.lower() |> str.replace(",", "")# "hello world!"Reads top-down: take raw, strip it, lowercase it, drop the comma. The same chain without pipes:
let cleaned: str = str.replace(str.lower(str.strip(raw)), ",", "")…which is the standard Python jump-around-and-read-inside-out.
How |> desugars
a |> f(arg) is exactly f(a, arg). Left-associative, the pipe argument fills the first positional slot:
result = x |> normalise() |> scale(2.0) |> clamp(0.0, 1.0)result = clamp(scale(normalise(x), 2.0), 0.0, 1.0)No partial application, no curry — just position-1 threading.
When pipes shine
- Long data-cleaning chains (strings, lists, DataFrames).
- Validation pipelines where each step returns the next stage’s input.
- Anywhere the inside-out call form is the reading bottleneck.
For one-step transformations, plain f(x) is fine.
comptime
comptime let bindings are evaluated by the compiler, at build time, and inlined as literals into the emitted Python. The most common use is env-var validation:
comptime let PORT: int = int(env("PORT", "8080"))comptime let DB_URL: str = env("DATABASE_URL") # build fails if unset[env]required = ["DATABASE_URL"]What this means:
tyc buildreads$PORTand$DATABASE_URLfrom the environment.- If
DATABASE_URLis missing, the build fails at compile time — not at the first request in production. - The values are inlined as literals in the emitted
.py:
# Emitted PythonPORT: int = 8080DB_URL: str = "postgresql://..."Build-time env validation, by itself, is worth the feature.
What comptime allows
Inside a comptime binding (or a comptime def) the compiler runs a sandboxed interpreter that supports:
- Integer / float / string / boolean literals.
- Basic arithmetic (
+ - * / //) and comparison ops. - Boolean ops (
and,or,not) and theEXPR if COND else EXPRternary. env(name, default?)lookups.int(),str(),float()casts.- Parameter and local-binding references.
- Calls to other
comptimefunctions.
It does not allow I/O, subprocesses, network access, random numbers, time functions, or imports of arbitrary modules. The sandbox is deliberately small.
comptime def functions
comptime def double(n: int) -> int: return n * 2
comptime def grade(score: int) -> str: if score >= 90: return "A" elif score >= 80: return "B" elif score >= 70: return "C" else: return "F"
comptime let PORT: int = double(4000) # → 8000comptime let MY_GRADE: str = grade(82) # → "B"The functions stay in the emitted Python (they’re ordinary defs — comptime is a build-time marker), so you can also call them at runtime if you want.
Lazy loading
Imports are eager in Python: importing numpy runs numpy/__init__.py even if you never call into it. For CLIs and short-running scripts, that’s wasted startup. lazy import defers the work:
lazy import np = numpy
def main() -> None: # numpy is not loaded yet if len(sys.argv) > 1: let arr: np.ndarray = np.array([1, 2, 3]) # loaded here, on first accessnp is a proxy object until you touch an attribute on it. The proxy is thread-safe — concurrent first accesses lock around the underlying module load using double-checked locking inside a generated __TyphonLazy_np_ class.
What’s not allowed
lazy from foo import a, b needs [python] target = "3.15" or later, where it lowers to PEP 810’s native lazy from-import. On 3.13 and 3.14 it is rejected with tyc::requires_newer_python:
lazy from numpy import array # ❌ before a 3.15 targetWithout PEP 810, a from-import has to load the module to bind the name, which defeats lazy loading. The diagnostic redirects you to:
lazy import numpy# ...numpy.array(...)lazy let for expensive module-level computation
lazy let CONFIG: Config = load_config_from_disk()This lowers to a sentinel-cached lazy_let(lambda: load_config_from_disk()) helper emitted into typhon_runtime. First access pays the load cost; subsequent accesses are a memory read. Unlike functools.cached_property (which is instance-scoped, race-prone, and writable after first evaluation), the module-level helper is robust under concurrency and one-shot.
Inside a class body, lazy let lowers to @cached_property because the per-instance scope is the intended semantics there.
lazy return types (roadmap)
def f(...) -> lazy[list[T]]: is the designed-but-not-yet-implemented
sibling of lazy let. The intent is for the body’s return [...] to
emit as a generator instead of materialising the list — useful when the
caller may only need a prefix. Today the parser rejects the form; write
a plain generator function (-> Iterator[int]: with yield) instead
until the lowering lands.
@pure and @memo
Typhon can verify that a function is pure: deterministic, side-effect-free, and safe to cache. The six conditions are:
- Synchronous. No coroutines or generators.
- Hashable parameters. Primitives, frozen dataclasses, tuples of hashables.
- No I/O. No
open,socket,subprocess,print, logger, DB. Unsafe calls count as impure unless their stub is annotated@pure. - No non-determinism. No
time.*,random.*,uuid.*,os.urandom. - No mutable module state. Reads from
comptime letare fine; reads from a modulemutare not. - No exceptions. Pure functions express failure through
Result[T, E].
When all six hold, the analyser may emit @functools.cache — but only when you opt in:
@puredef normalise(s: str) -> str: return s.strip().lower()
@memodef fib(n: int) -> int: if n < 2: return n return fib(n - 1) + fib(n - 2)
@pure(memo=True)def hash_password(salt: str, pw: str) -> str: ...@pureasserts purity — the compiler verifies the six conditions and raises a hard error if any fail.@memotriggers the cache insertion; the function must qualify as pure.@pure(memo=True)is the combined form.
Project-wide auto-memoisation
[strictness]auto-memoise = trueWith this on, any function the analyser infers as pure gets @functools.cache automatically. Disabled by default. Caches extend the lifetime of every argument and return value, which is not a transparent change — opt in deliberately.
What @memo emits
@memodef fib(n: int) -> int: ...import functools
@functools.cachedef fib(n: int) -> int: ...For bounded caches, use @memo(max=128), which lowers to @functools.lru_cache(maxsize=128).
unsafe blocks
When you genuinely need to talk to untyped Python — a vendor library without stubs, exploratory glue code — wrap it in an unsafe: region:
import some_messy_lib
def main() -> None: unsafe: let data = some_messy_lib.fetch() # the `unsafe:` region marks the dynamic boundary let first = data[0] let tag = first.get("tag")
# at the unsafe boundary, you must re-assert the type let tag_str: str = str(tag) # explicit castWhat’s special:
- Inside
unsafe:, expressions that would normally inferAnybind freely. - Values acquire a hidden
Unsafe[T]marker — visible in diagnostics, not in source. - An
Unsafe[T]cannot cross out of the block into a non-unsafecontext expecting a concreteT. You must re-assert: annotate, narrow, or cast.
The block lowers to if True: so existing scope rules apply unchanged. The type checker tracks an unsafe_depth counter and suppresses diagnostics inside it.
When to use unsafe
- One-off scripts that talk to an undocumented vendor API.
- The first day you adopt a third-party library, before writing a stub.
- Genuinely dynamic code that resists static typing (e.g.
evalor RPC stubs).
For anything long-lived, write a .dty stub instead.
.dty stubs
.dty is Typhon’s stub format — the dialect’s stricter version of .pyi. Write a .dty to describe a Python library’s API, and the compiler will:
- Check your code against the stub at build time.
- Emit a
.pyicompanion next to the.pyso mypy / pyright / etc. also benefit.
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) -> intThe Redis class corresponds to whatever the underlying redis-py exposes. Inside your .ty files, treat it as a fully-typed Typhon class.
Drift detection
tyc check --stubs compares each .dty against the runtime symbols of the module it describes (a Typhon port of mypy’s stubtest). Drift shows up as:
- Names declared in the stub but missing at runtime.
- Names present at runtime but missing in the stub.
- Signature mismatches between stub and implementation.
Drift surfaces as tyc::stub_mismatch diagnostics with the standard severity wiring. For an in-tree implementation, drift is a code-review fail. For third-party stubs, drift means the library was upgraded; update the stub and re-check.
Putting it together
A small but realistic module: a CLI that loads config at build time, lazily imports a heavy dependency, and memoises a hot lookup.
import sys
lazy import np = numpy
comptime let DB_URL: str = env("DATABASE_URL")comptime let MAX_BATCH: int = int(env("MAX_BATCH", "100"))
class Config: db: str batch: int
@puredef parse_arg(raw: str) -> Result[int, str]: if raw.isdigit(): return Ok(int(raw)) return Err(f"not a number: {raw}")
@memodef fib(n: int) -> int: if n < 2: return n return fib(n - 1) + fib(n - 2)
def main() -> None: let cfg: Config = Config(db=DB_URL, batch=MAX_BATCH)
match parse_arg(sys.argv[1] if len(sys.argv) > 1 else "10"): case Ok(n): let series: list[int] = [fib(i) for i in range(n)] let arr: np.ndarray = np.array(series) print(arr |> np.diff() |> np.max()) case Err(msg): print(f"bad input: {msg}")
if __name__ == "__main__": main()from __future__ import annotationsfrom typhon_runtime import Ok, Err, Resultimport dataclassesimport functoolsfrom typhon_runtime.lazy import lazy_import as __typhon_lazy_importimport sys
np = __typhon_lazy_import("numpy")DB_URL: str = "postgresql://localhost/app" # inlined from $DATABASE_URL at buildMAX_BATCH: int = 100 # inlined from env("MAX_BATCH", "100")
@dataclasses.dataclass(slots=True)class Config: db: str batch: int
def parse_arg(raw: str) -> Result[int, str]: if raw.isdigit(): return Ok(int(raw)) return Err(f"not a number: {raw}")
@functools.cachedef fib(n: int) -> int: if n < 2: return n return fib(n - 1) + fib(n - 2)
def main() -> None: cfg: Config = Config(db=DB_URL, batch=MAX_BATCH) match parse_arg(sys.argv[1] if len(sys.argv) > 1 else "10"): case Ok(n): series: list[int] = [fib(i) for i in range(n)] arr: np.ndarray = np.array(series) print(np.max(np.diff(arr))) case Err(msg): print(f"bad input: {msg}")
if __name__ == "__main__": main()Every Typhon-specific feature on the page is visible in the lowering: comptime let becomes an inlined literal, lazy import rewrites to a __typhon_lazy_import proxy, @pure disappears (the verdict lives in the checker), @memo becomes @functools.cache, and the pipe chain arr |> np.diff() |> np.max() folds to np.max(np.diff(arr)).
What this brings together:
comptime let DB_URLfails the build ifDATABASE_URLisn’t set — no surprises in prod.lazy import np = numpydefers the ~150 ms numpy import; if the user passes an invalid arg, numpy never loads.@pure parse_argis verifiably pure — no I/O, no globals, no exceptions, errors throughResult.@memo fibcaches recursive calls; the compiler has verifiedfibqualifies as pure.- The pipe chain (
arr |> np.diff() |> np.max()) reads top-down. matchon aResultis exhaustive — the compiler enforces bothOkandErrcases.
Common mistakes
Marking a function @pure that touches I/O
@puredef fetch(url: str) -> str: import urllib.request return urllib.request.urlopen(url).read().decode() # ❌ I/Oerror[tyc::impure_pure_fn]: `fetch` is annotated `@pure` but performs I/OEither drop @pure (and lose the memo eligibility) or refactor to take the body as a parameter.
comptime reading a runtime-only value
comptime let NOW: float = time.time() # ❌ comptime sandbox forbids time.*Fix: compute at runtime, cache with lazy let.
Calling lazy from foo import bar
lazy from numpy import array # ❌ rejected at parse timeFix: lazy import numpy, then use numpy.array(...).
Pulling an Unsafe[T] value out of an unsafe: block
def parse() -> int: unsafe: let v = messy_lib.get_int() return v # ❌ Unsafe[Any] cannot flow into a concrete `int` contextFix: re-assert inside or at the boundary:
def parse() -> int: unsafe: let v = messy_lib.get_int() let checked: int = int(v) return checkedWhat you’ve learned
- Pipes thread a value into the next call’s first positional slot.
comptimeevaluates bindings and functions at build time; fails the build on missing required env vars.lazy importdefers module loading until first attribute access;lazy fromis rejected.@pure/@memoverify the six purity conditions and opt intofunctools.cache.unsafe:is the lexical boundary into untyped Python; values must be re-asserted to cross out..dtystubs describe third-party APIs;tyc check --stubscatches drift.
Where next
You’ve walked the whole language. From here, the most useful reading is:
- Language Reference — the syntax-form-by-syntax-form spec.
- Type System — every shape the checker understands.
- How Typhon Lowers — what each construct compiles to.
- Recipes — worked examples for common real-world tasks.
- Diagnostics Catalog — every error and warning, with fixes.