The functions in the earlier chapters declare no types. C++ and Java make you declare the type of everything, and they check those types before the program runs. Python checks types at run time, only when an operation is actually attempted, and the book so far has leaned on that freedom.
On a small program you do not miss the declarations. On a large one you start to. A type error that a compiler would have caught now waits until the code runs, and sometimes that means it waits until a bug report. Python’s answer keeps the freedom and adds the safety net back on your terms. You annotate code with type hints, and a separate tool reads them and tells you, before you run anything, where the types do not line up. The hints are optional and the checking is a separate step, so you opt in as much as it pays off and no more.
A hint annotates a parameter, a return value, or a variable. You write a colon for parameters and variables, and an arrow for the return type:
# typed_basics.py
# Hints annotate parameters, returns, and variables. They do not
# change how the code runs; they let a checker and an editor reason
# about it.
def repeat(text: str, times: int) -> str:
return text * times
total: int = 0
for word in ["a", "bb", "ccc"]:
total += len(word)
print(repeat("ab", 3))
print(total)The container and optional types read the way you say them:
list[int], dict[str, float],
tuple[int, ...], and str | None for “a
string or nothing.”
The naming convention earlier used ALL_CAPS to signal a constant,
but that is only a hint to human readers. Final makes
it a hint the checker enforces: reassign a Final name
and the checker reports it, even though Python itself still allows
the assignment at run time.
# final_constants.py
# Final marks a name as a constant. Reassigning it is a type error,
# caught by the checker before the program runs.
from typing import Final
MAX_RETRIES: Final = 3
GREETING: Final[str] = "hello"
# MAX_RETRIES = 5 # ty: cannot assign to final name "MAX_RETRIES"
print(MAX_RETRIES, GREETING)You can give the type explicitly, as in Final[str],
or let it be inferred from the value, as with
MAX_RETRIES. Marking the values that are meant to stay
fixed turns a class of accidental reassignments into errors you hear
about at once.
You do not have to annotate everything, or anything. Add hints
one function at a time, and the unannotated code keeps working. The
checker treats whatever it cannot see as the type Any,
which is compatible with everything, so typed and untyped code live
together. This is gradual typing. Start untyped, then add
hints where they earn their keep: the public interfaces, the tricky
data, the code other people depend on. Throwaway scripts you leave
bare. When a value really is dynamic, Any is an honest
way to say so.
The hints do nothing on their own. You need a tool to read them.
This book uses ty, Astral’s
fast checker. You point it at your code:
ty check
It complains where the hints and the code disagree, and stays
quiet when they agree. Every runnable example in this book is
checked this way, and the build runs ty on every
change, so the code you read here checks as well as runs.
The goal is to discover mistakes before the program runs. Consider:
def area(width: int, height: int) -> int:
return width * height
area("3", 4) # ty: argument of type "str" is not assignable to "int"At run time area("3", 4) does not cause an error. It
returns "3333", because "3" * 4 is
perfectly good string repetition. The bug would surface much later,
far from the line that caused it. The checker discovers the problem
right away.
This feature fits the way Python already works. The earlier chapters relied on dynamic typing: a function accepts any object, and the only requirement is that the object supports the operations performed on it. The type is checked at run time, when the operation runs. This is often called duck typing: if it looks like a duck and quacks like a duck, treat it as a duck.
Structural typing is the static counterpart. Rather than wait for run time, a type checker verifies ahead of time that an object has the required shape: the right methods and attributes, whatever class it was declared as. Dynamic typing and structural typing are the same idea checked at different moments. Dynamic typing trusts the object when the code runs; structural typing proves the shape before the code runs.
A Protocol expresses that shape. Some statically typed
languages make you declare up front that a class “is a”
Drawable by inheriting from it. A Protocol
asks only that an object have the right shape, with no
inheritance:
# protocols.py
# A Protocol describes a required shape: any object with that shape
# qualifies, without inheriting from a base class.
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str: ...
class Circle:
def draw(self) -> str:
return "circle"
class Square:
def draw(self) -> str:
return "square"
def render(shape: Drawable) -> str: # accepts anything with draw()
return shape.draw()
print(render(Circle()))
print(render(Square()))Circle and Square never mention
Drawable, and both are accepted, because each has a
draw() of the right shape. Hand render()
an object with no draw() and ty rejects
it. You keep the flexibility of dynamic typing and gain the early
warning of static types.
Keep one thing straight: the hints do not change what the program
does. Python stores them and otherwise ignores them. A wrong type
that slips past the checker behaves exactly as it would have with no
hints at all. Checking is a separate step you run, like the tests.
If you need a guarantee at run time, you still write
isinstance, or use a library built to validate data.
The typeguard library
reads your existing annotations and enforces them at run time, and
Pydantic validates and
parses data against typed models, which is useful at the edges of a
program where untrusted input comes in. The hints themselves are for
the tools and for the reader. The run-time behavior is
unchanged.