Python Overload Decorator: Multiple Type Signatures
python overload decorator: Learn how to use Python's @overload decorator to define multiple type signatures, improving type checking and code clarity.
When a function accepts several distinct input types and returns different types depending on the input, a single type annotation cannot express that relationship accurately. The @overload decorator from the typing module lets you declare multiple signatures for the same function, so static type checkers can infer the correct return type for each call. This article explains how to use the python overload decorator correctly, what it does and does not do, and how it compares to runtime dispatch mechanisms.
What the Overload Decorator Solves
Consider a function that converts a value to a string. If the input is an integer, you might want a decimal string; if it is a float, you might want a fixed-precision string. A single annotation like def to_str(value: int | float) -> str works, but it does not tell the type checker that the return type is always str — that part is fine. The real problem arises when the return type depends on the input type in a more complex way, such as returning a list when given a list and a single value when given a scalar. Without overloads, the type checker must assume the most general return type, which often loses information.
The @overload decorator exists to solve this by letting you define multiple type signatures for one function. Each signature describes a distinct combination of argument types and the corresponding return type. The actual implementation is written once, with no decorator, and must be compatible with all declared overloads.
Declaring Overloads with @overload
To use the decorator, import overload from typing. You then write multiple function definitions, each decorated with @overload, followed by a single implementation function that has no decorator. The overloaded definitions contain only the signature and a docstring or ... as the body. They are never executed at runtime.
from typing import overload @overload def parse(value: int) -> int: ... @overload def parse(value: str) -> str: ... def parse(value): if isinstance(value, int): return value * 2 elif isinstance(value, str): return value.upper() else: raise TypeError("Unsupported type")
In this example, parse has two overloads: one for int returning int, and one for str returning str. The implementation function uses isinstance checks to handle both cases. At runtime, only the implementation exists; the overloaded definitions are ignored.
Writing the Implementation Function
The implementation function must be compatible with every overload. This means it must accept all argument types declared in the overloads, and its return type must be a union of the declared return types, or a supertype of them. The implementation itself is not type-checked against the overloads by default in most type checkers, but it should be written carefully to avoid runtime errors.
A common mistake is to put type annotations on the implementation that conflict with the overloads. For example, if you annotate the implementation as def parse(value: int) -> int, the type checker will complain because the overload for str is not covered. The implementation should either have no annotations or use a broad annotation such as def parse(value: int | str) -> int | str.
A Practical Example: Parsing Different Input Types
A more realistic scenario is a function that parses a configuration value. It might accept a string, a dictionary, or a list of strings, and return a structured object. Overloads let you express the exact return type for each input.
from typing import overload, Any @overload def load_config(path: str) -> dict[str, Any]: ... @overload def load_config(data: dict[str, Any]) -> dict[str, Any]: ... @overload def load_config(paths: list[str]) -> list[dict[str, Any]]: ... def load_config(source): if isinstance(source, str): with open(source) as f: return json.load(f) elif isinstance(source, dict): return source elif isinstance(source, list): return [load_config(p) if isinstance(p, str) else p for p in source] else: raise TypeError("Unsupported source type")
Here, the overloads tell the type checker that passing a string returns a single dictionary, passing a dictionary returns that dictionary, and passing a list of strings returns a list of dictionaries. Without overloads, the return type would have to be dict[str, Any] | list[dict[str, Any]], forcing callers to narrow the type manually.
How Type Checkers Resolve Overloads
Static type checkers like mypy, Pyright, and Pyre use overloads to select the correct signature for each call site. They evaluate the argument types in order and pick the first overload that matches. If none match, they report an error. This is why the order of overloads matters: put the most specific overloads first.
For example, if you have an overload for int and one for object, the int overload must come first. Otherwise, the object overload would match every integer, and the more specific one would never be used.
The resolution is purely static; it happens at type-check time, not at runtime. The @overload decorator has no effect on the function's behavior. It only exists in the type system.
Overload vs. functools.singledispatch
The @overload decorator is often confused with functools.singledispatch, which provides runtime dispatch based on the type of the first argument. The two serve different purposes: @overload is for type checking, while singledispatch is for actual runtime behavior.
| Aspect | typing.overload | functools.singledispatch |
|---|---|---|
| Purpose | Static type hints | Runtime dispatch |
| Execution | No runtime effect | Dispatches to registered functions |
| Type safety | Full type checking support | No type checking of return types |
| Use case | Multiple signatures for one function | Different implementations for different types |
You can combine both: use singledispatch to implement runtime behavior and @overload to provide precise type hints for the dispatch function. This is a common pattern in libraries that need both runtime flexibility and good type support.
Common Mistakes and Limitations
One common mistake is to put logic in the overloaded definitions. Since they are never executed, any code there is dead. Always use ... or a docstring as the body.
Another mistake is forgetting to include the implementation function. If you only have overloads and no implementation, the type checker will raise an error, and the function will be None at runtime.
Overloads also have a limitation: they cannot express relationships between multiple arguments. For example, you cannot say that if the first argument is a str, the second must be an int, and the return type depends on both. Overloads are purely positional and do not support constraints between parameters. For such cases, you may need to use TypeVar with bound or use a protocol.
Maintainability and Type Checker Compatibility
Adding overloads increases the surface area of your function's type contract. Every new overload must be kept in sync with the implementation, which adds maintenance overhead. If you change the implementation to accept a new type, you must add a corresponding overload or the type checker will report a mismatch.
Type checker compatibility is generally good, but there are subtle differences. Mypy and Pyright both support overloads, but the resolution algorithm can differ in edge cases, especially when overloads overlap. To avoid surprises, keep overloads disjoint and order them from specific to general. Also, note that overloads are not supported in Python versions before 3.5; they were introduced in typing in Python 3.5 and later moved to typing in 3.10, but the syntax remains the same.
When used correctly, the python overload decorator is a powerful tool for making your code easier to use and safer to refactor. It gives type checkers the information they need to catch errors at development time, without adding any runtime cost. The key is to treat overloads as a type-level contract and keep the implementation simple and focused.