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

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 str

This 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 basket

The 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 += 1
Annotate 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_node

from __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 rate

All 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 annotatingRarely worth it
Public functions and classesTwo line private helpers
Anything returning None sometimesObvious loop variables
Complicated container shapesfor i in range(10)
Code others will callThrowaway scripts
Long lived projectsA ten line one off

Common mistakes

  • Expecting Python to enforce annotations at runtime. It never does.
  • Using Any to silence a checker instead of fixing the type.
  • Annotating a parameter as list when Iterable would do.
  • Forgetting -> None on __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 for None.
  • 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 | None and always handle the None branch.
  • Add from __future__ import annotations at the top of typed modules.
  • Give complicated types an alias.
  • Run a type checker in your build, or the annotations are only comments.

Practice

  1. Annotate five functions of your own, including one that may return None.
  2. Write a function taking a callable and a list, annotated with Callable.
  3. Change a parameter from list[int] to Iterable[int] and explain what that permits.
  4. Define a type alias for a nested record type and use it in three signatures.
  5. 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.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →
Python

Trees and Graphs

A tree is a graph with no cycles and one root. Both are walked with the same two strategies - depth first with a stack, breadth first with a queue.

Read more
Python

Sorting Algorithms

Python sorts for you in n log n. Implementing bubble, insertion, merge and quick sort is still worth doing, because it teaches how algorithms are comp...

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.