How to Type-Hint a Python Function That Returns Different Types Based on Argument Count
In Python, designing flexible functions that accept a variable number of arguments and return either a scalar (for a single input) or a tuple (for multiple inputs) is a common pattern. However, typing this behavior using @typing.overload can quickly lead to cryptic type checker errors in tools like Pyright and Pylance.
The Problem: Pyright/Pylance Overload Inconsistencies
Consider a function that takes one or more integers and returns a single string if one argument is supplied, or a tuple of strings if multiple arguments are passed:
from typing import overload
def foo(val: int, *more_vals: int) -> str | tuple[str, ...]:
out = tuple(str(v) for v in (val, *more_vals))
return out[0] if not more_vals else out
When attempting to add @overload signatures, developers typically encounter two issues:
- Overlapping signatures: Defining
(val: int, *more_vals: int) -> tuple[str, ...]causes an overlap warning because it also matches a single argument call. - Implementation inconsistency: Defining
(val: int, val2: int, *more_vals: int) -> tuple[str, ...]triggers an error such as: "Overload implementation is not consistent with signature."
Why Does Pylance Complain?
The second error occurs because in Python, parameters without a slash (/) can be passed as keyword arguments. In the overload signature:
@overload
def foo(val: int, val2: int, *more_vals: int) -> tuple[str, ...]: ...
A caller is technically allowed to call foo(val=1, val2=2). However, the implementation signature def foo(val: int, *more_vals: int) does not define a parameter named val2, meaning calling foo(val=1, val2=2) at runtime would raise a TypeError: foo() got an unexpected keyword argument 'val2'. Type checkers enforce that the implementation can safely handle any valid call permitted by the overload signatures.
Solution 1: Positional-Only Parameters (Recommended)
Since functions accepting variadic inputs (*args) are almost universally intended to be called positionally, you can mark the arguments as positional-only using a forward slash (/). This removes the parameter names from keyword resolution and satisfies the type checker:
from typing import overload
@overload
def foo(val: int, /) -> str: ...
@overload
def foo(val: int, val2: int, /, *more_vals: int) -> tuple[str, ...]: ...
def foo(val: int, /, *more_vals: int) -> str | tuple[str, ...]:
out = tuple(str(v) for v in (val, *more_vals))
if not more_vals:
return out[0]
return out
How Type Checking Behaves:
foo(1)resolves strictly tostr.foo(1, 2)resolves totuple[str, ...].foo(1, 2, 3)resolves totuple[str, ...].- Calling
foo(val=1)will now produce an explicit type checking error indicating positional-only usage.
Solution 2: Broaden the Implementation with *args
If you prefer not to use positional-only syntax, you can relax the implementation signature to accept variable arguments directly. The type checker only validates callers against the @overload definitions, not the implementation signature:
from typing import overload
@overload
def foo(val: int) -> str: ...
@overload
def foo(val: int, val2: int, *more_vals: int) -> tuple[str, ...]: ...
def foo(*vals: int) -> str | tuple[str, ...]:
if not vals:
raise TypeError("foo() missing at least 1 required positional argument")
out = tuple(str(v) for v in vals)
return out[0] if len(out) == 1 else out
Bonus: Preserving Exact Tuple Lengths in Python 3.11+
If you ever need the return tuple to match the exact length or type composition of the inputs, you can leverage TypeVarTuple (PEP 646):
from typing import TypeVar, TypeVarTuple, overload
T = TypeVar("T")
Ts = TypeVarTuple("Ts")
@overload
def convert(val: T) -> str: ...
@overload
def convert(val: T, val2: T, *more_vals: *Ts) -> tuple[str, str, *tuple[str, ...]]: ...
def convert(*vals: object) -> str | tuple[str, ...]:
res = tuple(str(v) for v in vals)
return res[0] if len(res) == 1 else res
Summary
The key to resolving overload consistency errors when distinguishing single-argument vs. multi-argument functions is addressing keyword argument compatibility. By using positional-only arguments (/) across your overloads and implementation, you prevent keyword mismatches and give Pyright and Mypy the exact constraints needed to infer your return types accurately.