Python Module 11 – Type hints and static type checking
Overloads, ParamSpec, Self and @override
Give a function return types that depend on its arguments, keep decorator signatures with ParamSpec, chain methods with Self, and confirm overrides.
What you will learn
- Describe return types that depend on the argument types with @overload
- Type decorators that keep the wrapped function's parameters with ParamSpec and Concatenate
- Use Self for methods and alternate constructors that return the instance
- Use @override so that the checker confirms each method really overrides one
- Type **kwargs key by key with Unpack and a TypedDict
Before you start
What you’ll learn · Before you start
What you will learn
- Describe return types that depend on the argument types with @overload
- Type decorators that keep the wrapped function's parameters with ParamSpec and Concatenate
- Use Self for methods and alternate constructors that return the instance
- Use @override so that the checker confirms each method really overrides one
- Type **kwargs key by key with Unpack and a TypedDict
Before you start
On this page
A retry decorator: A function that takes a function and returns the one its name should refer to, usually a wrapper. @timed above def report(): has the same effect as report = timed(report) after it. More in “Decorators: wrapping functions cleanly” typed with Callable[..., Any] erases the signature of every
function it wraps: in this lesson’s run, mypy saw the decorated function as def (*Any, **Any) -> Any, and a call
with timeout="slow" passed the check. Plain annotations cannot describe such signatures, and four tools of the
typing module can.
overload: One of several signatures declared for a single function, each a stub marked @overload with ... as its body. A type checker gives a call the return type of the first stub that fits it; at run time only the implementation after the stubs runs. give one function several signatures, so a checker knows that clean("text")
returns a str and clean(b"data") returns bytes. ParamSpec: A type parameter that stands for a whole parameter list, written **P in def retry[**P, R]. A decorator typed Callable[P, R] in and out hands the wrapped function's parameters on, so wrong calls are still reported (Python 3.10, PEP 612). lets a decorator keep the
parameters of the function it wraps. Self type: The type Self, from typing (Python 3.11, PEP 673): the type of the object a method was called on. A method annotated -> Self returns a PagedQuery when called on a PagedQuery, so a chain of calls keeps a subclass's own methods. types a method that returns its own instance, so
a chain of calls keeps a subclass’s type. The @override: A decorator from typing (Python 3.12, PEP 698) that marks a method as overriding one in a base class. A type checker reports the method when no base class has a method of that name; at run time it only sets __override__ to True. decorator asks the
checker to confirm that a method really overrides one in a base class. Three more come later. Concatenate types
decorators that add or remove a function’s first parameters, Unpack with a TypedDict types **kwargs key by key, and
TypeVarTuple stands for any number of types.
An overload gives each argument type its own return type
clean() strips spaces from text or bytes, so a caller who passes a str should get a str back. Here it is with
overloads and with a union return type:
"""One function, two signatures: @overload tells the checker which type comes back for which argument."""
from typing import get_overloads, overload, reveal_type
@overload
def clean(value: str) -> str: ...
@overload
def clean(value: bytes) -> bytes: ...
def clean(value: str | bytes) -> str | bytes:
return value.strip()
def clean_union(value: str | bytes) -> str | bytes:
return value.strip()
name = clean(" Asha Rao ")
reveal_type(name)
print(name.casefold())
other = clean_union(" Ravi ")
reveal_type(other)
print(other.casefold())
print("overloads registered:", len(get_overloads(clean))) Output
asha rao ravi overloads registered: 2
Printed as an error (standard error)
Runtime type is 'str' Runtime type is 'str'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 clean.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
"""Runs `mypy --strict clean.py` through mypy's Python API and prints what the command prints."""
from mypy import api
print("$ mypy --version")
print(api.run(["--version"])[0], end="")
print("$ mypy --strict clean.py")
report, errors, status = api.run(["--strict", "clean.py"])
print(report + errors, end="") Output
$ mypy --version mypy 2.4.0 (compiled: yes) $ mypy --strict clean.py clean.py:18: note: Revealed type is "str" clean.py:22: note: Revealed type is "str | bytes" clean.py:23: error: Item "bytes" of "str | bytes" has no attribute "casefold" [union-attr] Found 1 error in 1 file (checked 1 source file)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_clean.py
The check scripts run mypy 2.4.0 from Python; the line after each dollar sign is the same check as a terminal
command. The two stubs marked @overload declare the signatures, with ... as their body; the
definition after them, without the decorator, is the implementation that actually runs and must accept every
argument the stubs allow. mypy picked the first stub that matched the call and showed str, so name.casefold()
passed. clean_union() does the same work with a union return type, and mypy reported the call to casefold(),
because a caller of that function can never know which of the two types it got back. Step through line 17, as mypy
checks it and as Python runs it:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The call. Line 17 calls clean() with a str. The file declares two signatures, each a stub marked @overload whose body is ..., and then one definition without the decorator that does the work.
- mypy tries stub 1. mypy compares the call with the stubs in the order they are written. Stub 1 takes a str and the argument is a str, so it fits, and mypy looks no further.
- The call gets str. The call takes the return type of the stub that fitted. reveal_type() showed str on line 18, so name.casefold() on line 19 passes the check.
- Python skips the stubs. At run time no stub is consulted. The definition without @overload came last and replaced them, so every call of clean() runs the implementation, which accepts str | bytes.
- The same answer, twice. The implementation returned a str, and casefold() printed asha rao. The checker's answer and the run agree, and only the checker needed the stubs to know it in advance.
- Stubs kept for tools. The decorator records every stub it marks, which is how typing.get_overloads(clean) counted 2 of them at the end of the run.
Use overloads when the return type follows from the argument types; a union return type is right only when the
result really can be either. The order of the stubs matters, because the checker uses the first one that fits, so
the most specific signature goes first. At run time only the implementation exists, and
typing.get_overloads() (Python 3.11) lists the stubs for tools that want them.
Checkpoint
Check: Overloads, a return type per argument type
ParamSpec keeps a decorated function’s signature
Here is the opening’s retry decorator, typed two ways around the same fetch(), with what each way leaves the
checker to know:
What the checker knows about a decorated function
Text description of the diagram
The diagram starts with one function, fetch(url: str, timeout: float = 5.0) -> bytes, and follows it through two decorators.
- On the first path, the decorator retry_loose(3) is typed with Callable[..., Any]. The signature is dropped, and the checker sees the decorated fetch as def (*Any, **Any) -> Any, so every call passes, right or wrong.
- On the second path, the decorator retry(3) is typed with Callable[P, R]. The ParamSpec P captures the parameters (url: str, timeout: float = ...) and R captures the return type bytes. The checker sees the decorated fetch with its original signature, so a wrong call is reported.
"""Two retry decorators: one typed with Callable[..., Any], one with ParamSpec, around the same function."""
import functools
from collections.abc import Callable
from typing import Any, reveal_type
def retry_loose(times: int) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorate(fn: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
for attempt in range(1, times + 1):
try:
return fn(*args, **kwargs)
except ConnectionError:
if attempt == times:
raise
return wrapper
return decorate
def retry[**P, R](times: int) -> Callable[[Callable[P, R]], Callable[P, R]]:
def decorate(fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
for attempt in range(1, times):
try:
return fn(*args, **kwargs)
except ConnectionError:
print(f" attempt {attempt} failed, trying again")
return fn(*args, **kwargs)
return wrapper
return decorate
calls = 0
@retry(3)
def fetch(url: str, timeout: float = 5.0) -> bytes:
global calls
calls += 1
if calls < 3:
raise ConnectionError("reset by peer")
return f"{url} in under {timeout}s".encode()
@retry_loose(3)
def fetch_loose(url: str, timeout: float = 5.0) -> bytes:
return url.encode()
reveal_type(fetch)
reveal_type(fetch_loose)
print(fetch("https://example.org"))
print(fetch_loose("https://example.org", timeout="slow"))
print(fetch("https://example.org", timeout="slow")) Output
attempt 1 failed, trying again attempt 2 failed, trying again b'https://example.org in under 5.0s' b'https://example.org' b'https://example.org in under slows'
Printed as an error (standard error)
Runtime type is 'function' Runtime type is 'function'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 retry.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
"""Runs `mypy --strict retry.py` through mypy's Python API and prints what the command prints."""
from mypy import api
print("$ mypy --version")
print(api.run(["--version"])[0], end="")
print("$ mypy --strict retry.py")
report, errors, status = api.run(["--strict", "retry.py"])
print(report + errors, end="") Output
$ mypy --version mypy 2.4.0 (compiled: yes) $ mypy --strict retry.py retry.py:52: note: Revealed type is "def (url: str, timeout: float =) -> bytes" retry.py:53: note: Revealed type is "def (*Any, **Any) -> Any" retry.py:56: error: Argument "timeout" to "fetch" has incompatible type "str"; expected "float" [arg-type] Found 1 error in 1 file (checked 1 source file)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_retry.py
retry_loose() is typed with Callable[..., Any], so the function it returns accepts any arguments and returns
Any: mypy showed def (*Any, **Any) -> Any and said nothing about timeout="slow". retry() declares a ParamSpec
P and a return type R in its type parameters, def retry[**P, R]. P captures the whole parameter list of the
wrapped function and R its return type, and the wrapper’s *args: P.args, **kwargs: P.kwargs passes them through,
so the decorated fetch keeps def (url: str, timeout: float =) -> bytes and the bad call on line 56 is reported.
Python still ran it, and the last line of output shows the string inside the result: only the checker reads
annotations.
Checkpoint
Check: ParamSpec in decorators
Self keeps the subclass type through a chain of calls
Each method of this query builder returns the instance, so calls chain, and a subclass adds page():
"""Methods that return the instance: annotated with Self, the chain keeps a subclass's own type."""
from typing import Self, reveal_type
class Query:
def __init__(self) -> None:
self.parts: list[str] = []
def where(self, condition: str) -> Self:
self.parts.append(f"where {condition}")
return self
def where_named(self, condition: str) -> "Query":
self.parts.append(f"where {condition}")
return self
class PagedQuery(Query):
def page(self, number: int) -> Self:
self.parts.append(f"page {number}")
return self
q = PagedQuery().where("city = 'Pune'").page(2)
reveal_type(q)
print(q.parts)
q2 = PagedQuery().where_named("city = 'Goa'").page(3)
print(q2.parts) Output
["where city = 'Pune'", 'page 2'] ["where city = 'Goa'", 'page 3']
Printed as an error (standard error)
Runtime type is 'PagedQuery'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 query.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
"""Runs `mypy --strict query.py` through mypy's Python API and prints what the command prints."""
from mypy import api
print("$ mypy --version")
print(api.run(["--version"])[0], end="")
print("$ mypy --strict query.py")
report, errors, status = api.run(["--strict", "query.py"])
print(report + errors, end="") Output
$ mypy --version mypy 2.4.0 (compiled: yes) $ mypy --strict query.py query.py:25: note: Revealed type is "query.PagedQuery" query.py:27: error: "Query" has no attribute "page" [attr-defined] Found 1 error in 1 file (checked 1 source file)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_query.py
where() is annotated -> Self (Python 3.11, PEP 673), so on a PagedQuery it returns a PagedQuery, and the
chain can go on to page(). where_named() names the class instead, and mypy reported the .page(3) call after it,
although the program ran both chains, because the object was a PagedQuery all along. Self describes “the type of
the object this method was called on”, which is what fluent builders, copy() methods and alternate constructors
return. Self is valid only inside a class, where it stands for that class or a subclass. Step through both
chains:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Two chains, one class. Both chains start with PagedQuery(), a subclass of Query that adds page(). Lines 24 and 27 differ only in which filter method they call first.
- where() returns Self. where() is annotated -> Self, the type of the object it was called on. Called on a PagedQuery, it gives the checker a PagedQuery back.
- page() can follow. The checker still knows the subclass, so .page(2) is found, and reveal_type() showed PagedQuery for the whole chain on line 25.
- where_named() names Query. where_named() does the same work but is annotated -> "Query". After it, the checker knows only the base class, whatever object came back.
- The chain breaks for mypy. Query has no page(), so mypy reported .page(3) on line 27, a line that is correct at run time.
- What actually ran. Both chains ran and printed their parts, because where_named() returned the same PagedQuery it was called on. Self changes what the checker knows, never what runs.
Checkpoint
Check: Self for methods that return the instance
@override turns a stale override into a checker error
The base class below renamed its method from send_message() to send(). The SMS subclass still overrides the old
name:
"""@override after a rename: the base method is now send(), and one subclass still overrides the old name."""
from typing import override
class Notifier:
def send(self, to: str, text: str) -> None:
print(f"plain message to {to}: {text}")
def deliver(self, to: str, text: str) -> None:
self.send(to, text)
class SmsNotifier(Notifier):
@override
def send_message(self, to: str, text: str) -> None:
print(f"SMS to {to}: {text[:20]}")
class EmailNotifier(Notifier):
def send(self, to: str, text: str) -> None:
print(f"email to {to}: {text}")
SmsNotifier().deliver("customer 17", "Your parcel is out for delivery")
EmailNotifier().deliver("customer 17", "Your invoice is ready")
print("marked:", getattr(SmsNotifier.send_message, "__override__", False), getattr(EmailNotifier.send, "__override__", False)) Output
plain message to customer 17: Your parcel is out for delivery email to customer 17: Your invoice is ready marked: True False
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 notifier.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
"""Runs `mypy --strict notifier.py` through mypy's Python API and prints what the command prints."""
from mypy import api
print("$ mypy --version")
print(api.run(["--version"])[0], end="")
print("$ mypy --strict notifier.py")
report, errors, status = api.run(["--strict", "notifier.py"])
print(report + errors, end="") Output
$ mypy --version mypy 2.4.0 (compiled: yes) $ mypy --strict notifier.py notifier.py:15: error: Method "send_message" is marked as an override, but no base method was found with this name [misc] Found 1 error in 1 file (checked 1 source file)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_notifier.py
Python ran the inherited send(), so customers got a plain message instead of an SMS, and nothing failed. The
@override decorator (Python 3.12, PEP 698) on send_message() made mypy look for a base method with that name and
report that there is none. At run time the decorator only sets __override__ on the method, as the last line of
output shows; the checking is the checker’s job. Follow the first delivery as Python runs it, then the check:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- A delivery starts. Line 24 asks an SmsNotifier to deliver a message. SmsNotifier defines no deliver(), so Python runs the one it inherits from Notifier.
- send() is looked up. deliver() calls self.send(). Python looks for send first on the object's own class, then on its base classes, in order.
- Not in the subclass. SmsNotifier has no send(). Its method is still called send_message(), the name the base class used before the rename.
- Found in the base. Notifier has send(), so Python calls it. Nothing in the lookup fails, because a method that overrides nothing is just one more method.
- The wrong message. The customer got a plain message instead of an SMS, as the first line of the run shows, and the program raised no error at all.
- What @override asks. Before the program runs, mypy reads @override on send_message() and looks for a method of that name in Notifier, the base class.
- The rename, reported. There is none, so mypy reported line 15, the very line that must change. Renaming the method to send() makes the SMS go out again.
Key idea
Mark every override with @override, and a renamed or removed base method becomes a checker error at the line that must change.
Checkpoint
Check: @override and a renamed base method
Which tool fixes which signature problem
Find the problem your signature has in the middle column:
| Tool, and since when | The problem it solves | At run time |
|---|---|---|
| Overloads, Python 3.5 | A return type that depends on the argument types | Only the implementation runs |
| ParamSpec, Python 3.10 | A decorator that would lose the wrapped function’s signature | Nothing changes |
| Self, Python 3.11 | A method that returns the instance, in subclasses too | Nothing changes |
| @override, Python 3.12 | An override whose base method was renamed or removed | It sets __override__ on the method |
Version note
The versions are those of the standard typing module; projects that support older Pythons import the same names
from the typing_extensions package.
Checkpoint
Check: Choosing the tool for a signature
Check yourself
Answer these questions one by one; the session begins with the tool you seem to know least.
Practice that adapts to you
Practise this lesson
11 questions about 12 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Decorators that keep signatures: Concatenate, async wrappers and a typed toolkit Pro: opens with a Pro, Premium or Ultimate pass
A decorator can add a parameter, remove one, or turn a function into a coroutine, and each change can be typed. This part types the common shapes, reproduces the call that only the checker catches, and ends with an exercise that uses all four tools of the lesson.
In this part:
- Supplying the first argument with Concatenate, and an async wrapper
- Decorator factories, the older spelling, and what Concatenate cannot do
- Five decorator shapes, typed, and two tests that keep them so
- Exercise: a typed toolkit
- An overload, a timing decorator and a fluent report, typed
- Results of the sample tests
1 diagram · 2 runnable examples (Python) · 1 exercise with sample tests · about 9 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Overloads, Self and @override in real code: the rules checkers enforce, reproduced Pro: opens with a Pro, Premium or Ultimate pass
Overloads have rules about order and overlap, Self has an older spelling that libraries still use, and @override can
be made mandatory. This part reproduces the errors behind each rule, then types **kwargs key by key.
In this part:
- Overload rules: a Literal flag, the fallback overload and two errors
- Self or a TypeVar, in an alternate constructor
- Making @override mandatory
- Typing **kwargs with Unpack, and shapes with TypeVarTuple
1 diagram · 7 runnable examples (Python) · about 9 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Mid · Warm-up
What does the @overload decorator do when the program runs?
Almost nothing. Each function marked @overload is a stub whose body is ..., and the definition after the stubs, without the decorator, replaces them as the real function, so every call runs the implementation whatever the argument types. The decorator only records each stub, which typing.get_overloads() can list from Python 3.11, and it replaces the stub with a function that raises NotImplementedError if it is ever called directly. The overloads exist for type checkers: they choose the first stub that fits a call and give the caller its exact return type. Since Python never reads the stubs, a stub that disagrees with the implementation is found only by a checker.
-
Mid to senior
Type a retry decorator so that callers of the decorated function keep autocompletion and get errors for wrong arguments. What goes wrong if it is typed with Callable[..., Any], and how do you test that the typing works?
-
Mid to senior
A function returns str for a str argument and bytes for a bytes argument, and today it is annotated -> str | bytes. When would you replace that with overloads, what does each choice cost the callers, and what mistakes do overloads invite?
-
Mid to staff
After a base class renamed send_message() to send(), one subclass's override silently stopped being called and customers got the wrong notification. How does @override prevent this, and how would you make it mandatory across a large codebase?
Key takeaways
- Overloads give each argument type its own return type; the first stub that fits wins, and only the implementation runs.
- A decorator typed
Callable[P, R]in and out keeps the wrapped function’s parameters, andCallable[..., Any]checks nothing. -> Selfreturns the type of the object a method was called on, so a chain on a subclass keeps its methods.@overridemakes the checker report a method that overrides nothing; at run time it only sets__override__.
Terms in this lesson
- @override
- A decorator from typing (Python 3.12, PEP 698) that marks a method as overriding one in a base class. A type checker reports the method when no base class has a method of that name; at run time it only sets __override__ to True.
- overload
- One of several signatures declared for a single function, each a stub marked @overload with ... as its body. A type checker gives a call the return type of the first stub that fits it; at run time only the implementation after the stubs runs.
- ParamSpec
- A type parameter that stands for a whole parameter list, written **P in def retry[**P, R]. A decorator typed Callable[P, R] in and out hands the wrapped function's parameters on, so wrong calls are still reported (Python 3.10, PEP 612).
- Self type
- The type Self, from typing (Python 3.11, PEP 673): the type of the object a method was called on. A method annotated -> Self returns a PagedQuery when called on a PagedQuery, so a chain of calls keeps a subclass's own methods.
References
- typing: overload and get_overloads (Python Software Foundation)
- typing: ParamSpec (Python Software Foundation)
- typing: Concatenate (Python Software Foundation)
- typing: Self (Python Software Foundation)
- typing: override (Python Software Foundation)
- typing: final (Python Software Foundation)
- typing: TypeVarTuple and Unpack (Python Software Foundation)
- Typing specification: overloads (The Python Typing Team)
- PEP 612: Parameter Specification Variables (Python Software Foundation)
- PEP 673: Self Type (Python Software Foundation)
- PEP 695: Type Parameter Syntax (Python Software Foundation)
- PEP 698: Override Decorator for Static Typing (Python Software Foundation)
- PEP 692: Using TypedDict for more precise **kwargs typing (Python Software Foundation)
- PEP 646: Variadic Generics (Python Software Foundation)
- typing: assert_type (Python Software Foundation)
- functools.wraps (Python Software Foundation)
- typing_extensions: backported and experimental type hints (The typing_extensions project)
- mypy documentation: error codes for optional checks (explicit-override) (The mypy project)
- Pyright documentation: configuration (reportImplicitOverride) (Microsoft)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress