Skip to main content
Python · lesson 29 of 34
Python advanced Lesson 29 of 34

Python Type Hints and Static Typing

Annotate Python code and run mypy on it: unions, narrowing, generics, Protocol, TypedDict and --strict, with the real errors each one catches.

By SkillByExample · Updated

Type hints annotate what a variable, parameter or return value should hold. Python itself ignores them at runtime; a static checker such as mypy reads them and reports mistakes before the code runs. Every example below is checked with mypy 2.4.0 and run with Python 3.13.

Hints don’t run — checkers do

The same file, run by Python and then checked by mypy.

def greet(name: str, times: int = 1) -> str:
    return ", ".join([f"Hello, {name}"] * times)

print(greet("Ana", 2))
print(greet(42))
print(greet.__annotations__)
$ python greet.py
Hello, Ana, Hello, Ana
Hello, 42
{'name': <class 'str'>, 'times': <class 'int'>, 'return': <class 'str'>}
$ mypy greet.py
greet.py:5: error: Argument 1 to "greet" has incompatible type "int"; expected "str"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

Python happily called greet(42) — the f-string turned the int into text. The annotations were stored in __annotations__ and otherwise ignored. mypy read them, found the mismatch on line 5, and named the rule (arg-type) so it can be looked up or silenced deliberately.

Unions, None and narrowing

X | None says a value may be missing, and mypy then insists you handle that case.

def find_user(user_id: int) -> str | None:
    return {1: "ana", 2: "bo"}.get(user_id)

def shout(user_id: int) -> str:
    name = find_user(user_id)
    return name.upper()

def shout_safe(user_id: int) -> str:
    name = find_user(user_id)
    if name is None:
        return "NOBODY"
    reveal_type(name)
    return name.upper()

print(shout_safe(3))
$ mypy narrowing.py
narrowing.py:6: error: Item "None" of "str | None" has no attribute "upper"  [union-attr]
narrowing.py:12: note: Revealed type is "str"
Found 1 error in 1 file (checked 1 source file)
$ python narrowing.py
NOBODY

shout would crash with AttributeError for an unknown id, and mypy caught it without running anything. In shout_safe, the is None check narrows the type: reveal_type (understood by the checker, and built in at runtime since 3.11 — it also prints to stderr when run) shows that after the check name is plain str. isinstance checks narrow the same way. Before 3.10 this was spelled Optional[str] or Union[str, None].

Collections and callables

Annotate parameters with the most general type you need: Iterable for “anything I can loop over”, Sequence when you index, Mapping for read-only dicts.

from collections.abc import Callable, Iterable, Mapping

def normalise(items: Iterable[str]) -> list[str]:
    return [item.strip().lower() for item in items]

def total(prices: Mapping[str, float]) -> float:
    return sum(prices.values())

def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
    return func(a, b)

print(normalise((" A ", "b ")), normalise(s for s in ["C"]))
print(total({"tea": 2.5, "cake": 4.0}))
print(apply(lambda x, y: x * y, 3, 4))
print(apply(max, 3, 4))

ages: dict[str, int] = {"ana": 31}
ages["bo"] = "twenty"
print(apply(len, 3, 4))
$ mypy collections_hints.py
collections_hints.py:18: error: Incompatible types in assignment (expression has type "str", target has type "int")  [assignment]
collections_hints.py:19: error: Argument 1 to "apply" has incompatible type "Callable[[Sized], int]"; expected "Callable[[int, int], int]"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)

Because normalise accepts Iterable[str], a tuple and a generator both type-check — a list[str] parameter would have rejected them. Callable[[int, int], int] describes a function value: max fits, but len takes one argument, so it doesn’t. The dict[str, int] annotation made storing a string a type error.

Generics: TypeVar and the 3.12 syntax

A type variable links the type that goes in to the type that comes out.

from collections.abc import Sequence
from typing import TypeVar

T = TypeVar("T")

def first(items: Sequence[T]) -> T | None:
    return items[0] if items else None

def last[U](items: Sequence[U]) -> U | None:     # Python 3.12+ syntax
    return items[-1] if items else None

n = first([1, 2, 3])
s = last(["a", "b"])
reveal_type(n)
reveal_type(s)
length: int = len(first(["x"]))
$ mypy generics.py
generics.py:14: note: Revealed type is "int | None"
generics.py:15: note: Revealed type is "str | None"
generics.py:16: error: Argument 1 to "len" has incompatible type "Sized | None"; expected "Sized"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

mypy substituted the caller’s element type: a list of ints gives int | None, a list of strs gives str | None — with -> Any both would be unchecked. The error on line 16 is real: first can return None, and len(None) would crash. (There mypy solved T from what len expects, hence Sized | None in the message.)

Generic classes

A class parameterised by a type variable lets Stack[int] and Stack[str] be distinct types.

class Stack[T]:
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        if not self._items:
            raise IndexError("pop from empty stack")
        return self._items.pop()

numbers: Stack[int] = Stack()
numbers.push(1)
numbers.push(2)
print(numbers.pop() + 10)
numbers.push("three")
$ mypy stack.py
stack.py:17: error: Argument 1 to "push" of "Stack" has incompatible type "str"; expected "int"  [arg-type]
Found 1 error in 1 file (checked 1 source file)
$ python stack.py
12

pop() is known to return int, so + 10 is fine; pushing a string into a Stack[int] is flagged. At runtime the push of "three" would have succeeded silently. Before 3.12, write class Stack(Generic[T]) with a module-level T = TypeVar("T").

Protocol: duck typing the checker understands

A Protocol describes the methods a type must have; no inheritance is required.

from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> str: ...

class Circle:
    def draw(self) -> str:
        return "()"

class Label:
    def draw(self) -> str:
        return "[label]"

class Sound:
    def play(self) -> str:
        return "beep"

def render(shapes: list[Drawable]) -> str:
    return " ".join(s.draw() for s in shapes)

print(render([Circle(), Label()]))
print(isinstance(Circle(), Drawable), isinstance(Sound(), Drawable))
render([Circle(), Sound()])
$ mypy protocols.py
protocols.py:24: error: List item 1 has incompatible type "Sound"; expected "Drawable"  [list-item]
Found 1 error in 1 file (checked 1 source file)
$ python protocols.py
() [label]
True False
Traceback (most recent call last):
  File "/home/user/py-demo/typing/protocols.py", line 24, in <module>
    render([Circle(), Sound()])
    ~~~~~~^^^^^^^^^^^^^^^^^^^^^
  File "/home/user/py-demo/typing/protocols.py", line 20, in render
    return " ".join(s.draw() for s in shapes)
           ~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/user/py-demo/typing/protocols.py", line 20, in <genexpr>
    return " ".join(s.draw() for s in shapes)
                    ^^^^^^
AttributeError: 'Sound' object has no attribute 'draw'

Neither Circle nor Label mentions Drawable, yet both satisfy it because they have a matching draw method. Sound has no draw, so mypy rejects the list item — and running the file shows the crash it predicted, on the last line. @runtime_checkable additionally allows isinstance checks, which only test that the method exists, not its signature.

TypedDict: shapes for plain dicts

When data stays a dict — JSON, for instance — TypedDict declares its keys and value types.

from typing import NotRequired, TypedDict

class User(TypedDict):
    id: int
    name: str
    role: NotRequired[str]

def describe(user: User) -> str:
    return f"{user['name']} ({user.get('role', 'member')})"

print(describe({"id": 1, "name": "Ana"}))
print(describe({"id": 2, "name": "Bo", "role": "admin"}))
describe({"id": "3", "name": "Cy"})
describe({"id": 4})
$ mypy typed_dict.py
typed_dict.py:13: error: Incompatible types (expression has type "str", TypedDict item "id" has type "int")  [typeddict-item]
typed_dict.py:14: error: Missing key "name" for TypedDict "User"  [typeddict-item]
Found 2 errors in 1 file (checked 1 source file)

Each wrong dict literal got a precise error, while role could be omitted because it’s NotRequired. At runtime a TypedDict is an ordinary dict — nothing here would be caught without the checker. For validation of data arriving at runtime, use pydantic (see security).

--strict

By default mypy skips functions that have no annotations at all. --strict turns on the optional checks, including requiring annotations everywhere.

def add(a, b):
    return a + b

def double(x: int) -> int:
    return add(x, x)

print(double(4))
$ mypy loose.py
Success: no issues found in 1 source file
$ mypy --strict loose.py
loose.py:1: error: Function is missing a type annotation  [no-untyped-def]
loose.py:5: error: Returning Any from function declared to return "int"  [no-any-return]
loose.py:5: error: Call to untyped function "add" in typed context  [no-untyped-call]
Found 3 errors in 1 file (checked 1 source file)

The default run passed because add is unannotated, so mypy treats it as returning Any and checks nothing that flows through it. --strict exposes that hole: double claims to return int but really returns whatever add returns. Strict mode is the right default for new code; in a large existing codebase, turn checks on gradually in pyproject.toml under [tool.mypy].

Practice

1. Annotate def mean(values): return sum(values) / len(values) so that it accepts any sequence of floats, and check it with mypy --strict.

Solution
from collections.abc import Sequence

def mean(values: Sequence[float]) -> float:
    return sum(values) / len(values)

print(mean([1, 2, 3.5]), mean((4.0, 6.0)))
$ mypy --strict mean.py
Success: no issues found in 1 source file
$ python mean.py
2.1666666666666665 5.0

Sequence is needed (not Iterable) because len requires a sized collection. Passing ints is accepted: for type checking, int is compatible wherever float is expected.

2. Fix shout from the narrowing example so mypy reports no errors, without changing find_user.

Solution
def find_user(user_id: int) -> str | None:
    return {1: "ana", 2: "bo"}.get(user_id)

def shout(user_id: int) -> str:
    name = find_user(user_id)
    if name is None:
        raise KeyError(user_id)
    return name.upper()

print(shout(1))
$ mypy shout.py
Success: no issues found in 1 source file
$ python shout.py
ANA

Raising in the None branch narrows name to str for the rest of the function, just like returning early.

3. Write a Protocol called SupportsClose with a close() -> None method and a function close_all(items: list[SupportsClose]) -> int that closes each and returns the count. Show that in-memory file objects (io.StringIO, io.BytesIO) satisfy it.

Solution
import io
from typing import Protocol

class SupportsClose(Protocol):
    def close(self) -> None: ...

def close_all(items: list[SupportsClose]) -> int:
    for item in items:
        item.close()
    return len(items)

buffers: list[SupportsClose] = [io.StringIO("a"), io.BytesIO(b"b")]
print(close_all(buffers))
$ mypy close_all.py
Success: no issues found in 1 source file
$ python close_all.py
2

StringIO and BytesIO never heard of SupportsClose, but their close methods match its signature, so they qualify structurally.

4. Make Stack from the generic-class example also have a peek() -> T | None method, then use reveal_type to confirm mypy’s view of Stack[str]().peek().

Solution
class Stack[T]:
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def peek(self) -> T | None:
        return self._items[-1] if self._items else None

words: Stack[str] = Stack()
words.push("hi")
reveal_type(words.peek())
$ mypy peek.py
peek.py:13: note: Revealed type is "str | None"
Success: no issues found in 1 source file

Frequently Asked Questions

Do Python type hints affect runtime performance?
Essentially no. The interpreter stores annotations but never checks them, so a wrongly typed call runs exactly as it would without hints. Checking happens ahead of time in tools such as mypy and pyright, or at runtime only if you use a validation library like pydantic.
What is the difference between Protocol and ABC?
An abstract base class uses nominal typing: a class satisfies it only by inheriting from it. A Protocol uses structural typing: any class with the required methods and signatures satisfies it, with no inheritance, which suits interfaces between modules that should not import each other.
What is TypeVar used for?
A TypeVar is a placeholder type that links parts of a signature, for example 'returns the same type as the elements it receives'. The checker substitutes the caller's concrete type, so first([1, 2]) is an int | None rather than Any. Python 3.12 adds the shorter def first[T](...) syntax for the same thing.
Should I use mypy or pyright?
Both implement the same typing standard and catch mostly the same errors. mypy is the reference checker and is easy to run in CI; pyright is faster and powers the Pylance extension in VS Code. Many projects run one in CI and get the other's feedback in the editor.
What does mypy --strict add?
--strict turns on a group of optional checks, most notably requiring every function to be annotated, forbidding implicit Any from untyped calls and warning about functions that return Any. It is a good default for new code; for an existing codebase, enable checks gradually.