@pure and @memo
@pure asserts a function meets six conditions. @memo adds @functools.cache after the check. @pure(memo=True) is the combined form.
The six conditions
A function is verifiable as pure only if all six hold:
- Synchronous. No coroutines, generators, or async generators.
- All parameter types are hashable. Primitives, frozen dataclasses, tuples of hashable types, sealed-union variants whose payloads are themselves hashable.
- No I/O in the transitive call graph. No
open,socket,subprocess, logger writes,print, DB drivers.unsafe-region and stubbed calls count as impure unless the stub is annotated@pure. - No non-determinism. No
time.*,random.*,secrets.*,uuid.*,os.urandom. - No reads from or writes to mutable module-level state. Reads from
comptime letare fine; reads from a modulemutare not. - No exceptions raised. Pure functions return
Result[T, E]to express failure.
@pure
Asserts purity. Verifies the six conditions; raises tyc::impure_pure_fn on violation.
@puredef normalise(s: str) -> str: return s.strip().lower()@pure is erased at emit — nothing decorates the emitted function.
@memo
Inserts @functools.cache. The function must qualify as pure.
@memodef fib(n: int) -> int: if n < 2: return n return fib(n - 1) + fib(n - 2)
def main() -> None: print(fib(10))import functools
@functools.cachedef fib(n: int) -> int: if n < 2: return n return fib(n - 1) + fib(n - 2)
def main() -> None: print(fib(10))import functools is auto-injected if needed.
Bounded cache
@memo(max=128)def expensive(args: ...) -> ...: ...Emits @functools.lru_cache(maxsize=128).
@pure(memo=True)
Combined form:
@pure(memo=True)def hash_password(salt: str, pw: str) -> str: ...Same effect as @pure + @memo together.
Project-wide auto-memoisation
[strictness]auto-memoise = trueWhen on, every function the analyser can prove pure gets @functools.lru_cache(maxsize=1024, typed=True) automatically — and only when its signature is cache-safe. The return type must be deeply immutable (scalars, str, tuple[...] / frozenset[...] of such, Result of such, enums, frozen classes whose fields are themselves immutable). Every parameter must be a sound cache key: arguments that compare equal must be indistinguishable to the function, so int, str, bytes, bool, None, Literal and enums qualify, while float (0.0 == -0.0), Decimal, callables and mutable classes do not; inside a tuple or a frozen-class field only bool, None, enums and key-safe frozen classes do, since (True,) == (1,). A function returning a list, an Iterator, a Callable or a mutable class is never auto-cached, because every caller would receive the same object; a recursive function is never auto-cached (the cache wrapper deepens every recursive frame); a function calling something the analyser cannot classify (a method on a value of unknown type, a module outside the pure stdlib allow-list, a @pure helper whose own check was inconclusive) is left alone. A plain @pure under auto-memoise or tyc build -O is held to the same bar — @pure is a purity claim, not a cacheability claim. The cache is typed (so 1, 1.0 and True never share an entry) and bounded (so it cannot retain every argument it ever saw). Off by default. Opt in deliberately.
PGO-guided memoisation
[strictness]pgo-memoise = truepgo-min-calls = 100When on, tyc build reads typhon-profile.json (produced by a prior tyc profile run) and promotes provably pure, cache-safe functions (the same bar as auto-memoise) whose observed call count meets pgo-min-calls to the same bounded, typed @functools.lru_cache. Missing profile file is not an error — PGO is best-effort.
This is the right shape for hot-loop helpers that aren’t worth manually marking but show up at the top of a profile.
When @pure fails
@puredef fetch(url: str) -> str: import urllib.request return urllib.request.urlopen(url).read().decode()error[tyc::impure_pure_fn]: `fetch` is annotated `@pure` but performs I/O (urllib.request.urlopen) ┌─ src/main.ty:3:12 │3 │ return urllib.request.urlopen(url).read().decode() │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ I/O is not allowed in `@pure` functionsFix: drop @pure, or refactor to take the body as a parameter (the caller does the I/O; the pure function processes the result).
What @pure proves
The verifier is syntactic. It rejects what it can prove impure — I/O and clock or entropy calls under any import alias (datetime.now() after from datetime import datetime, np.random.rand()), logging calls, I/O method names on any receiver (.read_text(), .write(), .send()), mutation of an argument or a module binding through a method or attribute write (REGISTRY.append(x), c.n = c.n + 1, next(it) on a parameter), a read of a mut or rebound module binding, and calls to same-module helpers that are not @pure. It accepts what it can prove pure — arithmetic, the pure builtins, constructors of same-module classes that run no code of their own (no hand-written __init__ / __post_init__, no foreign base), a fixed stdlib allow-list (math, re, json, itertools, functools, datetime constructors, …), non-mutating methods on a receiver of evident builtin type, mutation of a fresh local. Anything in between — a method on a value of unknown type, a third-party module, anything in decimal (every operation reads the thread’s context), a filesystem query such as Path.cwd(), heapq.heappush on a module list, a class-attribute read, an assert’s expression, recursion, a @pure helper whose own check was inconclusive — is trusted under @pure and @memo, but never acted on by auto-memoise, pgo-memoise or auto-parallel.
Why no implicit memoisation?
Caches change observable behaviour:
- The first call costs full computation; subsequent calls are O(1).
- Cached arguments and return values stay alive forever (or until cache eviction). For large args / large returns this matters.
- The cache is process-local. Different processes (workers, threads with separate event loops) won’t share.
These are tradeoffs, not free lunches. Opt in deliberately.
When to use which
| Want | Use |
|---|---|
| Assert purity but no cache | @pure |
| Cache, with purity check | @memo |
| Combined, with explicit max | @pure(memo=True) + @memo(max=N) (separate) |
| Project-wide caching of every pure fn | [strictness] auto-memoise = true |
| Cache only the actually-hot pure fns | [strictness] pgo-memoise = true plus tyc profile |
Where next
- Advanced Features (tour) — teaching page.
- Purity Errors (diagnostics) —
impure_pure_fnand friends. tyc profile— generating profile data.