No history yet

Complex Function Overloading

Beyond Union Types

You've seen how Union can specify that a function accepts multiple types, like Union[int, str]. This works well when the return type is the same regardless of the input. But what happens when the output type depends on the input type?

Consider a function that retrieves items. If you ask for one item by its index, you get that single item back. If you ask for a range of items using a slice, you get a list of items back. A simple type hint like def get(key: Union[int, slice]) -> Union[Item, list[Item]]: is not very precise. It tells the type checker that any of the inputs could lead to any of the outputs. Your IDE can't help you much because it doesn't know the connection between your specific input and the expected output.

This is where overloading comes in. The @overload decorator lets you define multiple, specific function signatures for a single function body. You are essentially providing a detailed map for the static type checker, telling it: "If the input looks like this, the output will look like that."

The Overload Syntax

Function overloading requires two parts: one or more @overload stubs and a single implementation. The stubs are for the type checker, and their function bodies contain only an ellipsis (...). The implementation is the actual code that runs, and it is not decorated with @overload.

from typing import overload

# Overload stub 1: for the type checker
@overload
def get_item(key: int) -> str:
    ...

# Overload stub 2: for the type checker
@overload
def get_item(key: slice) -> list[str]:
    ...

# The one REAL implementation: for the runtime
def get_item(key: int | slice) -> str | list[str]:
    items = ["a", "b", "c", "d"]
    if isinstance(key, int):
        # Logic for integer key
        return items[key]
    elif isinstance(key, slice):
        # Logic for slice key
        return items[key]
    else:
        # This part should be unreachable if types are correct
        raise TypeError("Invalid key type")

# --- Try it out ---
item_one = get_item(1)      # IDE knows this is a str
item_slice = get_item(slice(0, 2)) # IDE knows this is a list[str]

print(f"Single item: {item_one}")
print(f"Sliced items: {item_slice}")

Notice a few critical rules:

  1. Multiple Stubs, One Implementation: You define as many @overload signatures as you need to describe the function's behavior, but they are always followed by exactly one non-decorated implementation.
  2. Ellipsis Body: The body of each @overload stub must be .... This signals that it's just a declaration for static analysis and contains no runtime logic.
  3. Consistent Implementation: The implementation's signature must be general enough to be compatible with all the overload stubs. In the example, key: int | slice covers both int and slice from the stubs. The return type str | list[str] covers both possible outputs.
  4. Runtime Logic: The implementation contains the actual logic. It must use runtime checks like isinstance() to figure out which behavior to execute based on the arguments it receives.

The @overload stubs are ignored at runtime. Only the final implementation is executed. The stubs exist purely to guide static type checkers and improve the developer experience.

A Standard Library Pattern

This pattern is common in Python's standard library. For example, getting an item from a bytes object works just like our example. If you provide an integer index, you get an int (the byte value). If you provide a slice, you get a new bytes object.

Let's model this behavior.

from typing import overload

class MyBytes:
    def __init__(self, data: bytes):
        self._data = data

    # Overload for an integer index
    @overload
    def __getitem__(self, index: int) -> int:
        ...

    # Overload for a slice index
    @overload
    def __getitem__(self, index: slice) -> 'MyBytes':
        ...

    # The single implementation
    def __getitem__(self, index: int | slice) -> int | 'MyBytes':
        if isinstance(index, int):
            # Return the integer value of the byte at that index
            return self._data[index]
        elif isinstance(index, slice):
            # Return a new instance of our class with the sliced data
            return MyBytes(self._data[index])
        else:
            # This check is good practice
            raise TypeError(f"Index must be int or slice, not {type(index).__name__}")

# --- In Action ---
data = MyBytes(b'hello')

byte_value = data[1] # Type checker knows this is an int (value of 'e')
byte_slice = data[1:4] # Type checker knows this is a MyBytes object (value of b'ell')

print(f"Byte value: {byte_value}")
print(f"Byte slice data: {byte_slice._data}")

By using @overload on the __getitem__ special method, we give any tool that understands type hints a much clearer picture of how our class behaves. When a developer types data[1], their IDE can immediately infer the result is an int and provide relevant autocompletions. This precise feedback is impossible with a simple Union in the return type.

Now that you understand the mechanics, you can provide highly accurate type information for complex functions. This makes your code not only safer but also significantly easier for others (and your future self) to use.

Quiz Questions 1/5

What is the primary problem that the @overload decorator is designed to solve in Python's type hinting system?

Quiz Questions 2/5

When using the @overload decorator, what must the body of each decorated stub function contain?