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:

  1. Overlapping signatures: Defining (val: int, *more_vals: int) -> tuple[str, ...] causes an overlap warning because it also matches a single argument call.
  2. 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 to str.
  • foo(1, 2) resolves to tuple[str, ...].
  • foo(1, 2, 3) resolves to tuple[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.