Type a function signature¶
The question. You have a function and you want it fully typed: parameters (including defaults, *args, **kwargs), return type, and the special-case cases (generators, async, overloads). You want a single checklist so nothing gets missed.
The canonical shape is: annotate parameters after :, return after ->, use | None when None is a valid value, and prefer abstract types for parameters (Iterable[T]) but concrete types for returns (list[T]). The complete example below puts it together.
from collections.abc import Callable, Iterable
from typing import TypeVar
T = TypeVar('T')
R = TypeVar('R')
def batch_apply(
fn: Callable[[T], R], # function taking T, returning R
items: Iterable[T], # accepts list, tuple, generator
*,
batch_size: int = 100, # defaults OK; type = the non-None type
on_error: Callable[[T, Exception], None] | None = None, # X | None = None
) -> list[R]: # concrete return: callers can len()/index
"""Apply fn to each item, collecting results.
Call on_error(item, exc) on failure if provided, else re-raise.
"""
results: list[R] = []
for item in items:
try:
results.append(fn(item))
except Exception as e:
if on_error is None:
raise
on_error(item, e)
return results
# Demo — T=int, R=str here
def square_string(n: int) -> str:
return str(n * n)
print(batch_apply(square_string, [1, 2, 3]))
# Error path with on_error
def failing(n: int) -> str:
if n == 2:
raise ValueError('two is forbidden')
return str(n)
errors: list[tuple[int, str]] = []
print(batch_apply(failing, [1, 2, 3], on_error=lambda n, e: errors.append((n, str(e)))))
print('errors:', errors)
# Variant: generators — annotate what you YIELD, not what you return
from collections.abc import Iterator
def countdown(n: int) -> Iterator[int]:
while n > 0:
yield n
n -= 1
for i in countdown(3):
print(i)
# Iterator[int] is right for the common 'yield only' case.
# Generator[Y, S, R] matters only if .send() or a return value comes into play.
# Variant: @overload — when the return type depends on the inputs
from typing import overload
@overload
def parse(value: str) -> int: ...
@overload
def parse(value: str, *, as_float: bool) -> float: ...
def parse(value: str, *, as_float: bool = False) -> int | float:
# Only the @overload stubs above are seen by callers; this is the implementation.
return float(value) if as_float else int(value)
x = parse('42') # type-checker sees: int
y = parse('3.14', as_float=True) # type-checker sees: float
print(x, type(x).__name__)
print(y, type(y).__name__)
Why each piece earns its place¶
Abstract input, concrete output. Iterable[T] for a parameter accepts any iterable — lists, tuples, sets, generators — so callers don't have to materialise their data into a list just to pass it. The return is list[R] because callers will want to len(...), slice, or iterate twice — concrete types commit to the shape you actually deliver.
X | None = None has two parts. | None annotates the type; = None is the default value. You need both. The common mistake is timeout: float = None — the annotation is just float but None doesn't match. float | None = None is the right shape.
Callable[[T], R] types the function that's being passed in. The [T] is the parameter list, R is the return. TypeVar links the input and output types — when the caller passes a Callable[[int], str], T becomes int and R becomes str throughout the signature. That lets the checker flag batch_apply(square_string, ['a', 'b']) as wrong: square_string wants int, not str.
Keyword-only is marked with * in the signature, positional-only with /. Neither affects the annotations themselves — you annotate each parameter as normal. batch_size and on_error here are keyword-only because they sit after the *.
Trade-offs¶
Generators → Iterator[T] for the common case. The full type is Generator[Yield, Send, Return], but you only need that when .send() or a meaningful return value is involved. For a plain "yield a stream of values" function, Iterator[T] is enough.
Async functions annotate the awaited value. async def fetch(url: str) -> dict means "awaiting this gives a dict". The object type is technically Coroutine[Any, Any, dict], but you almost never need to write that — the type-checker understands async def.
Don't over-specify parameters. list[int] when the function only iterates is too restrictive — Iterable[int] works with lists, tuples, sets, generators. Broader input types are usually better for library-shaped code.
Don't under-specify returns. A missing return annotation makes the function Any at every call site — the checker stops helping downstream. If the function returns nothing meaningful, annotate -> None explicitly.
Overloads are for cases where the return type genuinely depends on inputs. One function, multiple signatures, one implementation at the bottom. Use them sparingly — they're a maintenance surface — but they're what lets callers see precise return types without runtime checks.
Related reading¶
- Type a data structure — how the types you pass into functions get declared.
- Work with optional values — handling
X | Noneon the caller side. - Avoid common typing mistakes — including the
= Nonevs| None = Nonetrap.