Type Hints: The Basics
Annotations describe what a function expects and returns. Python does not enforce them, and that is the point: they are documentation a tool can check.
- The syntax
- Python does not enforce them
- Why bother
- Built in collection types
- Optional and unions
- Mutable defaults
- Any, and why to avoid it
- Callables and iterables
- Classes
- Dataclasses use annotations as fields
- Type aliases
- Checking your code
- Gradual typing
- Where they help most
- Common mistakes
- Best practices
- Practice
- Conclusion
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
The syntax
def greet(name: str) -> str:
return f"Hello, {name}"
def net_price(amount: float, tax_rate: float = 0.18) -> float:
return round(amount * (1 + tax_rate), 2)
def log(message: str) -> None: # returns nothing
print(message)
count: int = 0
names: list[str] = []
settings: dict[str, str] = {}A colon after a parameter gives its type; an arrow before the colon gives the return type. A variable can be annotated the same way.
Python does not enforce them
def double(n: int) -> int:
return n * 2
print(double(5)) # 10
print(double("ab")) # abab - no error at all
print(double([1, 2])) # [1, 2, 1, 2]Annotations are ignored at runtime. They exist for readers, editors and static type checkers - separate programs that read your code without running it and report inconsistencies. Nothing in this note changes what Python does when the program runs.
def double(n: int) -> int:
return n * 2
print(double.__annotations__) # {'n': <class 'int'>, 'return': <class 'int'>}Why bother
# Without hints: what is `data`? What comes back?
def process(data, key, default):
...
# With hints: the question is answered
def process(
data: list[dict[str, int]],
key: str,
default: int = 0,
) -> dict[str, int]:
...- The signature documents itself, and cannot drift out of date the way a comment can.
- Editors offer accurate completion and catch typos in attribute names.
- A type checker finds a whole class of bug before the code runs.
- Refactoring is safer: changing a return type shows every caller that breaks.
Built in collection types
names: list[str] = ["Meera", "Arun"]
scores: dict[str, int] = {"Meera": 92}
point: tuple[int, int] = (3, 7)
unique: set[str] = {"a", "b"}
mixed: list[int | str] = [1, "two"]
# A tuple of unknown length, all the same type
values: tuple[int, ...] = (1, 2, 3)
# Nested
records: list[dict[str, list[int]]] = [{"scores": [1, 2]}]
def summarise(rows: list[dict[str, str]]) -> dict[str, int]:
return {"count": len(rows)}Since Python 3.9 the built in types are subscriptable directly. Older code uses List, Dict and Tuple from typing; both work, and the lowercase form is now preferred.
Optional and unions
def find_user(users: list[dict], user_id: int) -> dict | None:
"""Return the matching user, or None."""
for user in users:
if user["id"] == user_id:
return user
return None
def parse(value: str | int | float) -> float:
return float(value)
# The older equivalent forms
from typing import Optional, Union
def find_user_old(users: list, user_id: int) -> Optional[dict]:
...
def parse_old(value: Union[str, int, float]) -> float:
...X | None and Optional[X] mean exactly the same thing. The | form arrived in Python 3.10 and is now the usual way to write it.
def label(value: str | None) -> str:
# A checker insists you handle the None case before using string methods
if value is None:
return "unknown"
return value.upper() # safe: value is now known to be a strThis is the most valuable thing a type checker does. Marking a value as possibly None forces every caller to deal with it, which removes the most common runtime error in Python.
Mutable defaults
def add_item(item: str, basket: list[str] | None = None) -> list[str]:
if basket is None:
basket = []
basket.append(item)
return basketThe annotation makes the None default explicit rather than looking like an oversight.
Any, and why to avoid it
from typing import Any
def process(data: Any) -> Any: # says nothing; disables all checking
return data
def process_better(data: dict[str, str]) -> list[str]:
return sorted(data)
# object is stricter than Any: you can pass anything, but do almost nothing with it
def describe(value: object) -> str:
return f"{type(value).__name__}: {value!r}"Any switches off type checking for that value. Use it deliberately at a boundary where the type genuinely is not known, not as a way to silence a checker.
Callables and iterables
from collections.abc import Callable, Iterable, Iterator, Sequence, Mapping
def apply(func: Callable[[int], str], values: list[int]) -> list[str]:
"""func takes one int and returns a str."""
return [func(v) for v in values]
def apply_any(func: Callable[..., int]) -> int:
"""Any arguments, returns an int."""
return func()
def total(values: Iterable[int]) -> int:
"""Anything iterable: list, tuple, set, generator."""
return sum(values)
def first(values: Sequence[str]) -> str:
"""Needs indexing and len, so not a set or a generator."""
return values[0]
def lookup(data: Mapping[str, int], key: str) -> int:
"""Read only mapping: a dict works, but the function will not modify it."""
return data.get(key, 0)
def counter() -> Iterator[int]:
n = 0
while True:
yield n
n += 1Annotate parameters with the least specific type that works.Iterable[int]accepts a list, a tuple, a set and a generator;list[int]accepts only a list. Be permissive in what you accept and precise in what you return.
Classes
class Account:
balance: float # a class level annotation
owner: str
def __init__(self, owner: str, balance: float = 0.0) -> None:
self.owner = owner
self.balance = balance
def deposit(self, amount: float) -> float:
self.balance += amount
return self.balance
def transfer_to(self, other: "Account", amount: float) -> None:
"""The quotes allow a reference to the class being defined."""
self.balance -= amount
other.balance += amount
@classmethod
def from_dict(cls, data: dict[str, float | str]) -> "Account":
return cls(str(data["owner"]), float(data["balance"]))from __future__ import annotations # then quotes are unnecessary
class Node:
def __init__(self, value: int, next_node: Node | None = None) -> None:
self.value = value
self.next_node = next_nodefrom __future__ import annotations makes every annotation a string internally, so a class can refer to itself without quotes. It is a good default at the top of any module using types.
Dataclasses use annotations as fields
from dataclasses import dataclass, field
from datetime import date
@dataclass
class Invoice:
customer: str
amount: float
issued_on: date = field(default_factory=date.today)
lines: list[tuple[str, float]] = field(default_factory=list)
reference: str | None = None
def total(self) -> float:
return round(sum(price for _, price in self.lines), 2)Here the annotations are not optional documentation: they are what tells @dataclass which attributes are fields.
Type aliases
Record = dict[str, str | int]
RecordList = list[Record]
Handler = Callable[[Record], bool]
def filter_records(records: RecordList, handler: Handler) -> RecordList:
return [r for r in records if handler(r)]
# Python 3.12 has dedicated syntax
# type Record = dict[str, str | int]An alias gives a complicated type one readable name and one place to change it.
Checking your code
A static type checker is a separate program, not part of Python.
Run it over your source and it reports mismatches without executing anything.def net_price(amount: float, tax_rate: float = 0.18) -> float:
return round(amount * (1 + tax_rate), 2)
total: str = net_price(100) # a checker reports: float is not str
net_price("100") # a checker reports: str is not float
net_price(100, rate=0.05) # a checker reports: no parameter named rateAll three lines run without complaint in Python. All three are bugs, and a checker finds them in a second.
Gradual typing
# Start at the boundaries, where data enters and leaves
def load_records(path: str) -> list[dict]: # typed
...
def transform(records): # untyped, for now
...
def save(records: list[dict], path: str) -> None: # typed
...Type hints are opt in and can be added function by function. An untyped function is treated as accepting and returning Any, so it never blocks the rest. Start with the functions other code calls most.
Where they help most
| Worth annotating | Rarely worth it |
|---|---|
| Public functions and classes | Two line private helpers |
Anything returning None sometimes | Obvious loop variables |
| Complicated container shapes | for i in range(10) |
| Code others will call | Throwaway scripts |
| Long lived projects | A ten line one off |
Common mistakes
- Expecting Python to enforce annotations at runtime. It never does.
- Using
Anyto silence a checker instead of fixing the type. - Annotating a parameter as
listwhenIterablewould do. - Forgetting
-> Noneon__init__, which some checkers require. - Referring to a class inside its own body without quotes or the future import.
- Writing
Optional[X]and then using the value without checking forNone. - Annotating everything at once in a large existing project.
Best practices
- Annotate public functions first; leave short private helpers alone.
- Accept the broadest useful type, return the most specific one.
- Use
X | Noneand always handle theNonebranch. - Add
from __future__ import annotationsat the top of typed modules. - Give complicated types an alias.
- Run a type checker in your build, or the annotations are only comments.
Practice
- Annotate five functions of your own, including one that may return
None. - Write a function taking a callable and a list, annotated with
Callable. - Change a parameter from
list[int]toIterable[int]and explain what that permits. - Define a type alias for a nested record type and use it in three signatures.
- Write a function whose annotations are wrong and explain what a checker would report.
Conclusion
Type hints are documentation a machine can verify. Python ignores them; editors and checkers do not. Annotate the public surface, accept broad types and return precise ones, and make sure None is always visible in the signature.