Basic annotations¶
The syntax for annotating variables, function parameters, and return types. This notebook covers the shapes you'll use on 90% of everyday code.
Function parameters and return types¶
Put the type after a colon for parameters, and after a -> for the return:
def area(width: float, height: float) -> float:
return width * height
print(area(3.0, 4.5))
A function that returns nothing meaningful should be annotated -> None:
def log(message: str) -> None:
print(f"[log] {message}")
log("saved")
None as a return annotation means "this function is called for its side effect and returns nothing". A function with no -> ... at all is technically valid Python, but the type-checker will treat its return as Any — better to be explicit.
The built-in types¶
The usual suspects, and the names you use to annotate them:
| Type | Annotation | Example |
|---|---|---|
| Integer | int |
42 |
| Float | float |
3.14 |
| String | str |
"hello" |
| Boolean | bool |
True, False |
| Bytes | bytes |
b"\x00\x01" |
| None | None |
None |
Note that bool is technically a subclass of int in Python — type-checkers let you pass a bool where an int is expected, but not the other way around. 1 + True == 2 works for this reason (though most of the time you shouldn't rely on it).
Variable annotations¶
You can annotate variables too, though they're less often necessary because type-checkers infer the type from the assignment. The syntax:
count: int = 0
name: str = "Matthew"
pi: float = 3.14159
print(count, name, pi)
When is an annotation useful even though it's inferrable?
- The initial value is ambiguous.
results: list[int] = []tells the type-checker that the list will holdints — otherwise the empty list has inferred typelist[Any]. - You want to document an invariant.
timeout: float = 30is more readable thantimeout = 30.0, and protects against someone later writingtimeout = "30"thinking strings are fine. - Declaring without initialising.
processed: int(no value) introduces a name and its type without actually binding anything to it yet. This is occasionally useful in classes.
# Ambiguous without annotation
results: list[int] = []
# The type-checker will now flag:
# results.append("string") # error: expected int, got str
results.append(1)
results.append(2)
print(results)
Default arguments¶
Type comes before the default:
def greet(name: str, greeting: str = "Hello") -> str:
return f"{greeting}, {name}"
print(greet("Alice"))
print(greet("Alice", "Hi"))
The default doesn't have to be the same type as the annotation — the str = "Hello" says "this parameter is of type str, and its default is the string "Hello"". The annotation and the default are separate statements.
Keyword-only and positional-only¶
*args and **kwargs follow the same pattern:
def build_url(base: str, *parts: str, **params: str) -> str:
path = "/".join([base.rstrip("/")] + list(parts))
if params:
query = "&".join(f"{k}={v}" for k, v in params.items())
path = f"{path}?{query}"
return path
print(build_url("https://example.com", "users", "42", sort="name", limit="10"))
The annotation on *args or **kwargs describes the type of each individual element, not the tuple/dict itself. *parts: str means "each arg in parts is a str" — inside the function, parts has type tuple[str, ...].
Multiple types via union (|)¶
If a parameter can be one of several types, use X | Y. A common case is "this thing or None":
def format_price(pence: int, currency: str | None = None) -> str:
amount = f"£{pence / 100:.2f}"
if currency is None:
return amount
return f"{amount} ({currency})"
print(format_price(1250))
print(format_price(1250, "GBP"))
str | None means "either a string or None". Using None as a marker for "no value provided" is idiomatic in Python; X | None is how you type it.
Other unions come up too — int | float, str | bytes, dict | list — all work the same way. On Python 3.10+ the | syntax is preferred. On older versions, use Union[X, Y] and Optional[X] from the typing module.
Custom types¶
Your own classes work exactly like the built-ins — use the class name as the annotation:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
def distance_from_origin(p: Point) -> float:
return (p.x ** 2 + p.y ** 2) ** 0.5
print(distance_from_origin(Point(3.0, 4.0)))
The Point in p: Point is the literal class. If you move the class to a different module, your annotations follow the usual import rules.
Using Any as an escape hatch¶
When you can't (or don't want to) give something a precise type, Any from the typing module means "anything is permitted here; don't check":
from typing import Any
def store(key: str, value: Any) -> None:
# Can't predict what callers will store
pass
store("x", 42)
store("y", "hello")
store("z", [1, 2, 3])
Any is the type-checker's off-switch. It's fine to use — that's what gradual typing is for — but every Any is a place where type errors can slip past. Reach for it when a narrower type genuinely doesn't exist, not as a way to avoid thinking about the type.
Exercise¶
Annotate the following functions with appropriate type hints. Each has a comment describing what it does. Aim for the most specific type that's accurate.
# Fill in the annotations
def to_upper(text):
"""Return the text uppercased."""
return text.upper()
def is_even(n):
"""True if n is an even integer."""
return n % 2 == 0
def average(numbers, default=None):
"""Mean of a list of numbers, or `default` if the list is empty."""
if not numbers:
return default
return sum(numbers) / len(numbers)
def load_config(path, strict=False):
"""Load a config from a file path; raise on missing keys if strict is True."""
# Pretend-implementation
return {"host": "localhost", "port": 8080}
Solution
def to_upper(text: str) -> str:
return text.upper()
def is_even(n: int) -> bool:
return n % 2 == 0
def average(numbers: list[float], default: float | None = None) -> float | None:
if not numbers:
return default
return sum(numbers) / len(numbers)
def load_config(path: str, strict: bool = False) -> dict[str, str | int]:
return {"host": "localhost", "port": 8080}
A few things to note:
averagereturnsfloat | None— if the list is empty, the return is the default (which might beNone). Reflecting that in the return type is honest.load_config's return isdict[str, str | int]— the values are mixed types. We'll see cleaner ways to type heterogeneous dicts (TypedDict) in a later notebook.list[float]acceptslist[int]too, becauseintis considered a subtype offloatby most type-checkers.
Recap¶
- Parameter annotations go after
:, return annotation goes after->. - Annotate variables with
name: type = value— useful when the inferred type would be ambiguous. Noneis the return annotation for functions that return nothing.X | Yis a union of types;X | Noneis the standard "optional" shape.Anyis an escape hatch — use when a narrower type doesn't exist.
Next: Generics and collections — typing lists, dicts, tuples, and the elements they contain.