PYTHON / FUNCTIONS
Type hints on functions
Annotate parameter and return types on your functions, read them back from __annotations__, and know which errors hints catch and which they don't.
What you will learn
- Write parameter hints as name: Type and results as -> Type, using -> None for no result
- Inspect what you wrote at runtime through func.__annotations__
- Explain why CPython never enforces a hint and mypy or pyright does
- Spell collections precisely: list[int], dict[str, int], tuple[int, str], int | None
Understanding Type hints on functions
A type hint is an expression attached to a parameter after a colon, or to the whole function after an arrow: def add(a: int, b: int) -> int. When Python executes the def statement it evaluates those expressions once and files the results away in the function object's __annotations__ dictionary, keyed by parameter name plus the special key 'return'. Nothing else happens. At call time the interpreter does not look at that dictionary, does not compare it to the arguments, and does not convert anything.
So the point of hints is not runtime safety, it is that the contract becomes machine-readable. A static checker such as mypy or pyright reads every def and every call site in your project and reports mismatches without executing a line, which catches the whole class of bugs where a function that expects an int is handed None from three modules away. Editors use the same information for completion, jump-to-definition, and safe renames. The signature is the highest-value place to annotate because it is the boundary where a caller's assumptions meet the body's assumptions.
The vocabulary you need for functions is small: built-in generics such as list[str], dict[str, int] and tuple[int, str], the union int | None for values that may be absent, Callable[[int], int] for a function argument, and -> None for a function that exists only for its side effect. Because annotations are ordinary expressions evaluated at def time, a name you mention must already exist, which is why forward references are written as strings. Precision matters more than coverage: a hint that lies is worse than no hint, because the checker believes it and stops warning you.
def add(a: int, b: int) -> int:
return a + b
print(add(2, 3))
print(add("2", "3")) # no error: hints are not checked
print(add.__annotations__)
def greet(name: str, punct: str = "!") -> None:
print(f"Hello, {name}{punct}")
print(greet("Ada"))Annotations record the intended types for humans and tools; Python stores them in __annotations__ and never checks them at call time.
Worked examples
Functions that may return nothing
Shows why a lookup helper must be annotated int | None rather than int.
def find_age(people: dict[str, int], name: str) -> int | None:
return people.get(name)
def label(age: int | None) -> str:
if age is None:
return "unknown"
return f"{age} years"
people = {"ada": 36, "alan": 41}
print(find_age(people, "ada"))
print(find_age(people, "grace"))
print(label(find_age(people, "grace")))Example explained
Line 1dict[str, int] states that keys are strings and values are integers, so a bare dict hint would tell a checker nothing.
Line 2people.get(name) yields None for a missing key, so -> int would be a false promise and a checker would trust it.
Line 3int | None (Python 3.10+) is the same type as typing.Optional[int]; it means 'an int or the None object'.
Line 4Because label declares int | None, a checker demands the is None branch before the f-string touches age.
Hinting collections and function arguments
Annotating a callable parameter and a fixed-shape tuple return.
from typing import Callable
def apply_twice(func: Callable[[int], int], value: int) -> int:
return func(func(value))
def summarize(scores: list[float]) -> tuple[float, int]:
return sum(scores) / len(scores), len(scores)
print(apply_twice(lambda n: n * 3, 2))
print(summarize([1.0, 2.0, 6.0]))Example explained
Line 1Callable[[int], int] describes a function taking exactly one int and returning an int; the inner list holds the parameter types.
Line 2list[float] means any number of floats, so length is not part of the type.
Line 3tuple[float, int] is different: it fixes the length at two and types each position separately.
Line 4None of this is verified at runtime; apply_twice would still run if you passed a lambda returning a string.
Forward references and resolving them
Quoting a class name that does not exist yet, then turning the string back into the class.
from typing import get_type_hints
def clone(node: "Node") -> "Node":
return Node(node.value)
class Node:
def __init__(self, value: int) -> None:
self.value = value
print(clone.__annotations__)
print(get_type_hints(clone)["return"].__name__)
print(clone(Node(7)).value)Example explained
Line 1Writing Node unquoted here would raise NameError at def time, because the def runs before the class statement.
Line 2__annotations__ keeps the quoted hint as the plain string 'Node' — it was never evaluated as a name.
Line 3get_type_hints looks the string up in the function's module globals at call time, by which point Node exists.
Line 4The body works either way; quoting only delays when the name has to be resolvable.
Important notes
int | None inside an annotation is a real expression on Python 3.9 and earlier and raises TypeError: unsupported operand type(s) for |; use typing.Optional[int] there, or put from __future__ import annotations at the top of the file so all hints stay strings.
Plain Python ignores hints, but libraries that read __annotations__ themselves — dataclasses, pydantic, FastAPI, attrs — genuinely act on them, so inside those frameworks the annotation is executable behaviour, not a comment.
Common mistakes
Assuming a hint validates arguments: def add(a: int, b: int) accepts "2" and "3" and returns "23", so the bug surfaces later where that string meets arithmetic and the traceback blames an innocent line.
Annotating -> int on a function whose early return has no value or that ends with dict.get: callers see a promised int, do age + 1, and hit TypeError: unsupported operand type(s) for +: 'NoneType' and 'int' while the checker stays silent.
Typing = where : belongs, as in def scale(factor = float): the parameter has no annotation at all and defaults to the type object float, so factor * 2 raises TypeError: unsupported operand type(s) for *: 'type' and 'int'.
Try it yourself
Change, predict, then run
Write def initials(first: str, last: str, dotted: bool = True) -> str that returns "A.L." when dotted is true and "AL" otherwise, then print initials.__annotations__ and confirm that calling initials("Ada", "Lovelace", dotted=1) still runs without complaint.
Open the Python workspaceCheck your understanding
Given def half(n: int) -> float: return n / 2, what happens when you call half("8")?
- Python raises TypeError from the annotation before the body runs
- The body runs and fails with TypeError: unsupported operand type(s) for /: 'str' and 'int'
- Python converts "8" using the int hint and returns 4.0
- The annotation is ignored and the function returns None
Show answer
The hint was evaluated once at def time and stored in half.__annotations__; the call never consults it, so execution enters the body and the division operator is what objects to a str. Option A is tempting because the error type is right, but the failure comes from n / 2 inside the body, not from any argument check — and a static checker would have flagged the call before you ran it. Nothing in the language coerces arguments to match a hint, which rules out option C.