Python type hints are annotations you attach to names, parameters and return values so that a type checker - mypy, pyright, or the checker built into your editor - can tell you about mistakes before you run the code. They change nothing about how Python executes your program, which is exactly why they are cheap to adopt and easy to misuse.
What do Python type hints actually do?
An annotation is metadata stored next to the function. Python does not enforce it at runtime. That single fact explains both the appeal and the traps.
def double(value: int) -> int:
"""Return twice the given value."""
return value * 2
print(double(3)) # 6
print(double("3")) # 33 - no error, because nothing checks annotations at runtime
The second call is legal Python. The annotation says what the author intended, not what the interpreter guarantees. A type checker reading the same file would flag the call, and your editor would underline it while you type - that is where the value of hints lives.
Two practical consequences follow. First, you can annotate a codebase one function at a time; untyped code is simply skipped by the checker rather than rejected. Second, the annotations are the best documentation you will ever write for a function, because they are checked. A docstring can lie; a wrong annotation is a bug report waiting to happen.
There is a third consequence that catches newcomers out: a checker can only trust what you tell it. A function annotated as -> int that secretly returns a string is not a runtime error, but every caller that trusted the annotation is now wrong. Annotations are a contract you keep, and the checker is the auditor that reminds you when you stop keeping it.
Which annotation syntax should you use today?
Modern Python lets you write types the way you would say them out loud. The old typing.List and typing.Optional spellings still work, but nothing new needs them.
from __future__ import annotations
def average(values: list[float]) -> float:
"""Return the arithmetic mean of a non-empty sequence."""
return sum(values) / len(values)
def find_user(user_id: int) -> str | None:
"""Return the user's name, or None when there is no such user."""
users = {1: "ada", 2: "grace"}
return users.get(user_id)
print(average([1, 2, 3, 4]))
print(find_user(1))
print(find_user(9))
| Meaning | Modern syntax | Older equivalent |
|---|---|---|
| A list of strings | list[str] | List[str] |
| Possibly missing | str | None | Optional[str] |
| Either type | int | float | Union[int, float] |
| Exactly one of a set | Literal["a", "b"] | Literal["a", "b"] |
| Anything at all | Any | Any |
from __future__ import annotations is worth adding at the top of a module that uses annotations. It tells the interpreter to store every annotation as a string instead of evaluating it, which means forward references work without quotes, the module imports faster, and you can annotate with types that are only imported under TYPE_CHECKING.
How do you describe the shape of your data?
A TypedDict describes a dictionary with known keys. It is a type-checking device only - at runtime it is just a dict - and it is the right way to describe JSON payloads that arrive from an API or a file.
from typing import Literal, TypedDict
class Settings(TypedDict):
"""Shape of the settings dictionary read from disk."""
theme: Literal["light", "dark"]
retries: int
def describe(settings: Settings) -> str:
"""Return a one-line description of the settings."""
return f"{settings['theme']} theme, {settings['retries']} retries"
print(describe({"theme": "dark", "retries": 3}))
The Literal annotation is doing quiet work: a typo such as "darkk" becomes a checker error rather than a value that silently fails to match anything at runtime.
How do you type a function that only needs part of an object?
Inheritance is the wrong tool when all you need is "something with a close method". A Protocol describes a shape without requiring anyone to inherit from it, so any object with the right methods fits - including classes you did not write and cannot change.
from typing import Protocol
class Closer(Protocol):
"""Anything that can be closed."""
def close(self) -> None: ...
def close_all(closers: list[Closer]) -> int:
"""Close every item and return how many were closed."""
for closer in closers:
closer.close()
return len(closers)
class Socket:
"""Pretend this wraps a network socket."""
def close(self) -> None:
print("socket closed")
print(close_all([Socket(), Socket()]))
Protocols are the typing feature that pays off fastest in real projects, because they let you state what you need from a collaborator instead of demanding a particular class hierarchy. The ... body is a placeholder meaning "this method has no implementation here"; it is valid Python and does nothing at runtime.
Write a protocol when you are tempted to type a parameter as a concrete class but you only use two or three of its methods. Write an abstract base class instead when the shared behaviour is substantial and you want every subclass to inherit it - a protocol describes an interface, an abstract base class provides one.
Can you inspect annotations at runtime?
Yes, and occasionally you need to. func.__annotations__ holds the raw annotations, and typing.get_type_hints resolves strings into real type objects - which matters when a module uses from __future__ import annotations, because then every annotation is stored as text.
from typing import get_type_hints
def total(price: float, tax: float = 0.2) -> float:
"""Return the price including tax."""
return price * (1 + tax)
hints = get_type_hints(total)
print(hints["price"].__name__, hints["tax"].__name__, hints["return"].__name__)
print(total.__annotations__)
Frameworks use this to validate incoming data, serialise function signatures and build dependency injection containers. It is also how you can write a small decorator that logs the types it sees at call time.
One warning: get_type_hints can raise NameError if an annotation refers to a name that is not importable at runtime. That happens with forward references in modules using from __future__ import annotations when the referenced type lives behind if TYPE_CHECKING:. If you hit it, the fix is to make the type available at runtime, not to stop using annotations.
Generic classes work the same way, with a TypeVar standing in for the type of the contents.
from typing import Generic, TypeVar
T = TypeVar("T")
class Stack(Generic[T]):
"""A last-in, first-out container for items of type T."""
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
stack: Stack[str] = Stack()
stack.push("first")
print(stack.pop())
Recap
- Annotations are documentation that a checker can verify; Python itself ignores them at runtime.
- Write modern syntax:
list[str],str | None, and addfrom __future__ import annotationsat the top of the module. - Use
TypedDictandLiteralto describe dictionary payloads from JSON or config files. - Use
Protocolwhen you care about behaviour, not inheritance. - Annotate the public surface first - parameters and return values - and let the checker find the rest for you.
Try it yourself: Take a function from an earlier lesson of this tutorial and annotate it completely: parameter types, return type, and a TypedDict for the dictionary it returns. Run it to confirm nothing breaks - then deliberately pass a wrong type and watch the error appear in your editor's squiggle rather than at runtime. If your editor does not check types, install pyright or mypy and run it on the file; the count of complaints on the rest of your project is usually a good next exercise.