Callable overloads

When using literal types, you’re enouraged to provide a fallback overload:

However, one important use case type checkers must take care to support is the ability to use a fallback when the user is not using literal types. For example, consider open :

But for simple functions, this overload will be identical to the implementation, leading to unnecessary duplication. If overloads were callable, this could be fixed:

from contextlib import suppress
from typing import IO, Any, Literal, _overload_registry

def overload(func):
    f = getattr(func, "__func__", func)
    with suppress(AttributeError):
        _overload_registry[f.__module__][f.__qualname__][f.__code__.co_firstlineno] = func
    return func

@overload
def fopen(path: str, mode: Literal["r", "w", "a", "x"]) -> IO[str]:
    ...

@overload
def fopen(path: str, mode: Literal["rb", "wb", "ab", "xb"]) -> IO[bytes]:
    ...

@overload
def fopen(path: str, mode: str) -> IO[Any]:
    return open(path, mode, encoding="utf-8")

Why pollute the typing namespace with an ambiguous name that only is useful in fairly rare cases[1], if you could just change the specification and implementation for overload instead to allow the final use of overload to contain an implementation? That makes a lot more sense to me than implementation as a separate decorator, which as a name doesn’t even really have anything to do with overloads. Even overload(implementation=True) would be better.

It’s perhaps a bit more error prone in the meantime, since you would have to remember to import overload from typing_extensions, but for a fairly niche use-case and minor improvement I think that’s better than adding an additional decorator you have to learn about.


  1. By its very definition the implementation will overlap with all of the overloads, often in a detrimental way, where you have to rely on type checker heuristics in order for it to pick the most precise overload, so you don’t want to add the implementation signature to the overloads, literals are kind of the exception here ↩︎

It would probably be easier to just make all overloads callable and rely on your type checker to detect mistakes.

That was precisely my initial idea, but it’s not possible to add keyword arguments to overload in an elegant way without breaking old code.

Why not? The standard decorator pattern works perfectly find for this.

def overload(func_or_none = None, /, *, implementation=False):
    def decorator(func):
        ...

    if func_or_None is None:
        return decorator
    else:
        return decorator(func_or_none)

Yeah, I guess that does work, but it’s not pretty:

def overload(func=None, *, implementation=False):
    def decorator(func):
        f = getattr(func, "__func__", func)
        with suppress(AttributeError):
            _overload_registry[f.__module__][f.__qualname__][f.__code__.co_firstlineno] = func
        return func if implementation else _overload_dummy
    return decorator if func is None else decorator(func)

Correction: callable is a builtin name.

The priority for stdlib APIs should be that they’re easy to use and teach, how “pretty”[1] their implementation is really doesn’t matter almost at all, just don’t do anything obviously bad, when there is a better way with no other downsides.


  1. beauty is in the eye of the beholder and everyone’s idea of good/readable/clean code is different ↩︎

2 Likes