Skip to content

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:

Python
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 ->:

Python
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:

Python
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:

Python
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:

Python
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:

Python
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:

Python
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:

Python
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.