Functions
Functions in Typhon look like typed Python functions, with two extra rules: every parameter and return type must be annotated, and the checker enforces those annotations at every call site.
The shape of a function
def add(a: int, b: int) -> int: return a + bThree required pieces:
- A name (
add). - Annotated parameters (
a: int, b: int). - A return type (
-> int).
Omit any of them and tyc check complains. There is no inference fallback for signatures.
Default arguments
Defaults work the way they do in Python, but the default value must match the annotation:
def greet(name: str = "world", punctuation: str = "!") -> str: return f"Hello, {name}{punctuation}"
greet() # "Hello, world!"greet("Alice") # "Hello, Alice!"greet(punctuation=".") # "Hello, world."A mismatched default is rejected at the definition site:
def bad(n: int = "zero") -> int: ... # ❌ tyc::type_mismatch on the defaultReturning nothing
-> None is the explicit form. Don’t omit the annotation hoping it’ll be inferred — that’s a hard error.
def log(msg: str) -> None: print(f"[log] {msg}")A function declared -> None may use return with no value, or run off the end. It may not return any other value.
Returning multiple values
Use a tuple; the call site destructures with normal Python syntax:
def divmod_pair(a: int, b: int) -> tuple[int, int]: return a // b, a % b
let q, r = divmod_pair(17, 5) # q = 3, r = 2If two of those values mean different things, prefer a small class or model (see Classes and Models) — readers shouldn’t have to remember which slot is which.
Optional parameters via T?
A nullable parameter type lets the caller pass None. It does not auto-default to None:
def find(name: str?) -> int?: if name is None: return None return len(name)
find(None) # ✅find("alice") # ✅find() # ❌ missing positional argumentIf you want both — nullable and defaulted — be explicit:
def find(name: str? = None) -> int?: ...Keyword-only and positional-only parameters
Python’s * and / separators work unchanged:
def connect(host: str, /, *, port: int = 5432, ssl: bool = True) -> None: ...
connect("localhost", port=5433) # ✅connect(host="localhost") # ❌ host is positional-onlyconnect("localhost", 5433) # ❌ port is keyword-only*args and **kwargs
Allowed, but each must be annotated — Rule 1 (every parameter annotated) extends to variadic parameters since v0.9.0:
def log_all(*messages: str, **tags: str) -> None: let joined: str = ", ".join(messages) let tag_pairs: str = " ".join(f"{k}={v}" for k, v in tags.items()) print(f"[log] {joined} ({tag_pairs})")
log_all("start", "ready", env="prod", region="eu")Inside the body, messages has type tuple[str, ...] and tags has type dict[str, str].
If the function is genuinely variadic — typically a generic decorator or a **kwargs forwarder where the shape can’t be pinned down at the signature — the canonical idiom is object:
def trace[R](f: Callable[..., R], *args: object, **kwargs: object) -> R: log(f.__name__, args, kwargs) return f(*args, **kwargs)object is the honest spelling for “I don’t know what’s coming through”; the body still needs to narrow with isinstance to do anything type-specific. Avoid Any as the escape hatch — *args: Any silently drops every check at the boundary.
First-class functions
Functions are values. Annotate them with Callable:
from collections.abc import Callable
def apply(f: Callable[[int], int], x: int) -> int: return f(x)
def double(n: int) -> int: return n * 2
apply(double, 7) # 14Callable[[A, B], R] is “takes A and B, returns R”. For a no-arg callable, use Callable[[], R].
Lambdas
Single-expression anonymous functions; the parameter and return types are inferred from context:
let nums: list[int] = [1, 2, 3, 4]let squares: list[int] = [x * x for x in nums]
# Or with map:let doubled: list[int] = list(map(lambda n: n * 2, nums))If the context can’t pin down the parameter type, you’ll get an “implicit Any” error. Promote to a named function when that happens — lambdas are best kept short.
Generic functions
Typhon uses PEP 695 syntax for generics. Type parameters live in square brackets after the function name:
def first[T](xs: list[T]) -> T?: if len(xs) == 0: return None return xs[0]
let n: int? = first([1, 2, 3]) # T inferred as intlet s: str? = first(["a", "b"]) # T inferred as strInference is bidirectional and recursive:
def pair[T](a: T, b: T) -> tuple[T, T]: return (a, b)
let p = pair(1, "two") # T = int | str (widening)When the type cannot be inferred (e.g. empty list), let the binding type drive inference back through the call:
let empty: int? = first([]) # ✅ T inferred as int from the bindingDon’t try to write first[int]([]) — explicit type instantiation at the call site is rejected at check time since v0.9.0 (it used to crash at runtime with 'function' object is not subscriptable). The fix is always to annotate the binding or the surrounding context so the expected type drives inference.
Generics are covered in depth in Generics and Interfaces and Generics (PEP 695).
Decorators
Decorators work as in Python; Typhon adds a few of its own.
import functools
@functools.cachedef fib_plain(n: int) -> int: if n < 2: return n return fib_plain(n - 1) + fib_plain(n - 2)
@memo # Typhon: inserts @functools.cache after purity checkdef fib(n: int) -> int: if n < 2: return n return fib(n - 1) + fib(n - 2)
@pure # Typhon: enforces the six purity conditionsdef normalise(s: str) -> str: return s.strip().lower()@pure, @memo, @pure(memo=True), and @gatherable are Typhon-specific decorators. See @pure and @memo for the full reference.
Common mistakes
Missing return annotation
def add(a: int, b: int): return a + berror[tyc::missing_annotation]: `return type` on `add` is missing a type annotationFix: def add(a: int, b: int) -> int:.
Inconsistent return types
def lookup(k: str) -> int: if k == "": return None # ❌ None not assignable to int return 42Fix the annotation (-> int?) or the return value.
Calling a function with the wrong arity
def add(a: int, b: int) -> int: ...
add(1) # ❌ missing argument `b`add(1, 2, 3) # ❌ too many argumentsThe diagnostic points at the call site, not the definition.
Forgetting to await an async function
async def fetch() -> str: ...
def main() -> None: let s: str = fetch() # ❌ coroutine, not strThis is a hard error in Typhon — not a runtime warning. See Async and Concurrency.
Putting it together
A small CLI-style example:
import sys
def parse_args(argv: list[str]) -> tuple[str, int]: let name: str = argv[1] if len(argv) > 1 else "world" let times: int = int(argv[2]) if len(argv) > 2 else 1 return name, times
def greet(name: str, times: int = 1) -> None: for _ in range(times): print(f"Hello, {name}")
def main() -> None: let name, times = parse_args(sys.argv) greet(name, times)
if __name__ == "__main__": main()from __future__ import annotationsimport sys
def parse_args(argv: list[str]) -> tuple[str, int]: name: str = argv[1] if len(argv) > 1 else "world" times: int = int(argv[2]) if len(argv) > 2 else 1 return (name, times)
def greet(name: str, times: int = 1) -> None: for _ in range(times): print(f"Hello, {name}")
def main() -> None: (name, times) = parse_args(sys.argv) greet(name, times)
if __name__ == "__main__": main()Run it:
tyc buildpython build/main.py Alice 3# Hello, Alice# Hello, Alice# Hello, AliceWhat you’ve learned
- Every parameter and return type is annotated; the checker enforces both.
- Defaults,
*args,**kwargs, and keyword-only parameters work as in Python. Callable[[...], R]types first-class functions.- Generic functions use
def f[T](...)(PEP 695 syntax). - Mismatched arities, returns, and missing awaits are all compile errors.
Where next
- Control Flow —
if,while,for, lists, dicts, sets, tuples, comprehensions, and theguardstatement. - Generics and Interfaces — type parameters and structural typing in detail.
@pureand@memo— the purity decorator reference.