Type Hints
Type hints are annotations you attach to variables, function parameters, and return values to describe what kind of data is expected. Python doesn't enforce them at runtime.
They're for tooling (linters, type checkers, IDEs) and for the next person who reads your code.
A function without hints works fine.
A function with hints communicates is easier to understand:
def slugify(name: str) -> str:
return name.strip().lower().replace(" ", "-")
Reading the signature alone tells you slugify takes a string and returns a string. No need to scan the body.
Annotating Functions
Parameters get annotated with parameter: Type. The return type goes after a ->:
def add(a: int, b: int) -> int:
return a + b
def greet(name: str) -> None:
print(f"Hello, {name}")
-> None means the function returns nothing useful. Use it for functions that exist for their side effects.
Annotating Variables
You can annotate variables the same way, though it's only common when the type isn't obvious from the value:
project_name: str = "release-tracker"
counts: dict[str, int] = {}
For a literal like project_name = "release-tracker", the annotation is redundant.
For an empty container like counts = {}, the annotation is the only thing telling the type checker what's supposed to go in.
Built-in Generic Types
Containers carry a type parameter. A list of strings looks like this:
def split_tags(raw: str) -> list[str]:
return [tag.strip() for tag in raw.split(",")]
Common shapes:
list[str]: a list of strings.dict[str, int]: a dictionary mapping strings to integers.tuple[int, int]: a fixed-size pair of integers.set[str]: a set of strings.
A Short History Aside
Warning
Older code (Python 3.8 and earlier) couldn't subscript the built-in containers. You'd see:
from typing import List, Dict
def split_tags(raw: str) -> List[str]: ...
Python 3.9 made the built-ins themselves usable as generics, so list[str] works directly. For new code, prefer the lowercase built-ins and skip the imports from typing.
The Union Operator
A value that can be a string or None shows up everywhere: optional function parameters, fields that haven't been set yet, lookup results that might miss. The pipe operator joins types into a union:
def find_project(slug: str) -> Project | None: ...
def archive(name: str, reason: str | None = None) -> None: ...
str | None means "a string, or None". int | float | str means "any of those three". The pipe syntax came in with PEP 604 in Python 3.10.
You may run into the older form in existing codebases:
from typing import Optional, Union
def find_project(slug: str) -> Optional[Project]: ...
def parse(value: str) -> Union[int, float]: ...
Optional[X] is exactly X | None. Union[A, B] is exactly A | B. Both still work. Prefer the pipe syntax for new code.
Hints Don't Validate
Type hints are not runtime checks. Python won't stop you from doing this:
def add(a: int, b: int) -> int:
return a + b
add("hello", "world") # runs, returns "helloworld"
Python itself happily concatenates the strings and returns.
A type checker like mypy will can verify, and provide hints in VS Code.
Note
When we need actual runtime validation (incoming API payloads, config files, anything from outside your code), we'll use Pydantic.