Skip to content

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 + b

Three 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 default

Returning 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 = 2

If 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 argument

If 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-only
connect("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) # 14

Callable[[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 int
let s: str? = first(["a", "b"]) # T inferred as str

Inference 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 binding

Don’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.cache
def 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 check
def fib(n: int) -> int:
if n < 2: return n
return fib(n - 1) + fib(n - 2)
@pure # Typhon: enforces the six purity conditions
def 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 + b
error[tyc::missing_annotation]: `return type` on `add` is missing a type annotation

Fix: 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 42

Fix 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 arguments

The 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 str

This 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()

Run it:

Terminal window
tyc build
python build/main.py Alice 3
# Hello, Alice
# Hello, Alice
# Hello, Alice

What 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