PEP 9999 – Generalized overload types
- Author:
- Benjy Wiener <benjywiener at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Draft
- Type:
- Standards Track
- Topic:
- Typing
- Created:
- 04-Jan-2026
- Python-Version:
- 3.15
- Post-History:
- 28-Dec-2025
Abstract
This PEP proposes a generalized overloading mechanism, expanding on the existing @typing.overload decorator
semantics, via a new OverloadedType special form. The goal is to enable developers to describe arbitray type
transformations in a concise, flexible, and resuable manner, allowing more complex and dynamic code to be accurately
typed.
Motivation
Currently, typing.overload allows developers to define arbitrary relationships between function input types
and return types. However, this functionality is limited to functions, and does not scale well.
Class-level Overloading
As an example of code that would benefit from overloading at the class level, consider this pattern, common to data encoding/decoding implementations:
from enum import IntEnum
class DataType(IntEnum):
UINT8 = 0
UINT64 = 1
STRING = 2
class Message:
data_type: DataType
value: int | str
def encode(self) -> bytes:
if self.data_type is DataType.UINT8:
# Type checker error: Cannot access attribute "to_bytes" for class "str"
encoded_value = self.value.to_bytes(1, 'little')
elif self.data_type is DataType.UINT64:
# Type checker error: Cannot access attribute "to_bytes" for class "str"
encoded_value = self.value.to_bytes(8, 'little')
elif self.data_type is DataType.STRING:
# Type checker error: Cannot access attribute "encode" for class "int"
encoded_value = self.value.encode('utf-8')
else:
assert_never(self.data_type)
return self.data_type.to_bytes(1) + encoded_value
There is currently no way to express to type checkers the relationship between data_type and value,
requiring such code to resort to cast or assert to satisfy the type checker.
Overload explosion
Another motivation for this PEP is to avoid the combinatorial explosion of overloads required to type certain
constructs. As a real-world example, take subprocess.Popen.__init__ method from the official typeshed
stubs (source):
if sys.version_info >= (3, 11):
# process_group is added in 3.11
@overload
def __init__(
self: Popen[str],
args: _CMD,
bufsize: int = -1,
executable: StrOrBytesPath | None = None,
stdin: _FILE | None = None,
stdout: _FILE | None = None,
stderr: _FILE | None = None,
preexec_fn: Callable[[], object] | None = None,
close_fds: bool = True,
shell: bool = False,
cwd: StrOrBytesPath | None = None,
env: _ENV | None = None,
universal_newlines: bool | None = None,
startupinfo: Any | None = None,
creationflags: int = 0,
restore_signals: bool = True,
start_new_session: bool = False,
pass_fds: Collection[int] = (),
*,
text: bool | None = None,
encoding: str,
errors: str | None = None,
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
pipesize: int = -1,
process_group: int | None = None,
) -> None: ...
@overload
def __init__(
self: Popen[str],
args: _CMD,
bufsize: int = -1,
executable: StrOrBytesPath | None = None,
stdin: _FILE | None = None,
stdout: _FILE | None = None,
stderr: _FILE | None = None,
preexec_fn: Callable[[], object] | None = None,
close_fds: bool = True,
shell: bool = False,
cwd: StrOrBytesPath | None = None,
env: _ENV | None = None,
universal_newlines: bool | None = None,
startupinfo: Any | None = None,
creationflags: int = 0,
restore_signals: bool = True,
start_new_session: bool = False,
pass_fds: Collection[int] = (),
*,
text: bool | None = None,
encoding: str | None = None,
errors: str,
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
pipesize: int = -1,
process_group: int | None = None,
) -> None: ...
@overload
def __init__(
self: Popen[str],
args: _CMD,
bufsize: int = -1,
executable: StrOrBytesPath | None = None,
stdin: _FILE | None = None,
stdout: _FILE | None = None,
stderr: _FILE | None = None,
preexec_fn: Callable[[], object] | None = None,
close_fds: bool = True,
shell: bool = False,
cwd: StrOrBytesPath | None = None,
env: _ENV | None = None,
*,
universal_newlines: Literal[True],
startupinfo: Any | None = None,
creationflags: int = 0,
restore_signals: bool = True,
start_new_session: bool = False,
pass_fds: Collection[int] = (),
# where the *real* keyword only args start
text: bool | None = None,
encoding: str | None = None,
errors: str | None = None,
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
pipesize: int = -1,
process_group: int | None = None,
) -> None: ...
@overload
def __init__(
self: Popen[str],
args: _CMD,
bufsize: int = -1,
executable: StrOrBytesPath | None = None,
stdin: _FILE | None = None,
stdout: _FILE | None = None,
stderr: _FILE | None = None,
preexec_fn: Callable[[], object] | None = None,
close_fds: bool = True,
shell: bool = False,
cwd: StrOrBytesPath | None = None,
env: _ENV | None = None,
universal_newlines: bool | None = None,
startupinfo: Any | None = None,
creationflags: int = 0,
restore_signals: bool = True,
start_new_session: bool = False,
pass_fds: Collection[int] = (),
*,
text: Literal[True],
encoding: str | None = None,
errors: str | None = None,
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
pipesize: int = -1,
process_group: int | None = None,
) -> None: ...
@overload
def __init__(
self: Popen[bytes],
args: _CMD,
bufsize: int = -1,
executable: StrOrBytesPath | None = None,
stdin: _FILE | None = None,
stdout: _FILE | None = None,
stderr: _FILE | None = None,
preexec_fn: Callable[[], object] | None = None,
close_fds: bool = True,
shell: bool = False,
cwd: StrOrBytesPath | None = None,
env: _ENV | None = None,
universal_newlines: Literal[False] | None = None,
startupinfo: Any | None = None,
creationflags: int = 0,
restore_signals: bool = True,
start_new_session: bool = False,
pass_fds: Collection[int] = (),
*,
text: Literal[False] | None = None,
encoding: None = None,
errors: None = None,
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
pipesize: int = -1,
process_group: int | None = None,
) -> None: ...
@overload
def __init__(
self: Popen[Any],
args: _CMD,
bufsize: int = -1,
executable: StrOrBytesPath | None = None,
stdin: _FILE | None = None,
stdout: _FILE | None = None,
stderr: _FILE | None = None,
preexec_fn: Callable[[], object] | None = None,
close_fds: bool = True,
shell: bool = False,
cwd: StrOrBytesPath | None = None,
env: _ENV | None = None,
universal_newlines: bool | None = None,
startupinfo: Any | None = None,
creationflags: int = 0,
restore_signals: bool = True,
start_new_session: bool = False,
pass_fds: Collection[int] = (),
*,
text: bool | None = None,
encoding: str | None = None,
errors: str | None = None,
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
pipesize: int = -1,
process_group: int | None = None,
) -> None: ...
# (more overloads for previous Python versions omitted)
To properly type the various combinations of universal_newlines, text, encoding,
and errors, the entire method signature must be duplicated six times. In order to accurately type the
stdin, stdout, and stderr attributes on the resulting Popen instance (see this issue),
that number goes up to 36 [1]!
With this proposal, all of those overloads can be expressed with two OverloadedTypes and a single __init__ declaration.
Specification
This PEP introduces a new class, typing.OverloadedType, which can be used to define arbitrary type transformations,
using syntax similar to typing.overload.
An OverloadedType definition consists of a subclass of OverloadedType with one or more annotated methods named _ defined
in its body. Each method, or ‘overload’, specifies a single overload case, where the parameter annotations are taken as the input types,
and the return annotation as the output type.
Note
All overloads are implicitly @staticmethods. Additionally, keyword-only parameters are not allowed.
Once defined, an OverloadedType is used as a special generic typing form, where the type arguments correspond to the input types
of the overloads, and the resulting type is determined by matching the provided type arguments against the overloads, using the
semantics defined in the overload spec.
More precisely, given an OverloadedType, SomeOverloadedType, when a type checker encounters an expression of the form
SomeOverloadedType[T1, T2, ..., Tn], it should perform the following steps:
- Generate an overloaded function definition from the overloads defined in
SomeOverloadedType. - Perform overload resolution on the generated function, using
T1, T2, ..., Tnas the argument types. - If a matching overload is found, the resulting type is the return type of the matched overload, with type variables substituted as necessary.
- If no matching overload is found, a type error is raised.
Examples
Class-level overloading, single type parameter:
from enum import IntEnum
from typing import Literal, OverloadedType
class DataType(IntEnum):
UINT8 = 0
UINT64 = 1
STRING = 2
class DataTypeToValueType(OverloadedType):
def _(_: Literal[DataType.UINT8]) -> int: ...
def _(_: Literal[DataType.UINT64]) -> int: ...
def _(_: Literal[DataType.STRING]) -> str: ...
class Message[DataTypeT: (
Literal[DataType.UINT8],
Literal[DataType.UINT64],
Literal[DataType.STRING],
)]:
data_type: DataTypeT
value: DataTypeToValueType[DataTypeT]
def encode(self) -> bytes:
if self.data_type is DataType.UINT8:
# Type of self.value is inferred to be int
encoded_value = self.value.to_bytes(1, 'little')
elif self.data_type is DataType.UINT64:
# Type of self.value is inferred to be int
encoded_value = self.value.to_bytes(8, 'little')
elif self.data_type is DataType.STRING:
# Type of self.value is inferred to be str
encoded_value = self.value.encode('utf-8')
else:
assert_never(self.data_type)
return self.data_type.to_bytes(1, 'little') + encoded_value
Factored-out function overloads, multiple type parameters:
from enum import Enum, global_enum
from typing import IO, Literal, OverloadedType
# Make PIPE a distinct type
@global_enum
class _PopenFileSpecial(Enum):
PIPE = -1
PIPE: Final = _PopenFileSpecial.PIPE
class TextOptionsToStringType(OverloadedType):
def _(
univeral_newlines: Literal[False] | None,
text: Literal[False] | None,
encoding: None,
errors: None,
) -> bytes: ...
def _(
univeral_newlines: Literal[True],
text: Literal[True] | None,
encoding: str | None,
errors: str | None,
) -> str: ...
def _(
univeral_newlines: Literal[True] | None,
text: Literal[True],
encoding: str | None,
errors: str | None,
) -> str: ...
def _(
univeral_newlines: Literal[True] | None,
text: Literal[True] | None,
encoding: str,
errors: str | None,
) -> str: ...
def _(
univeral_newlines: Literal[True] | None,
text: Literal[True] | None,
encoding: str | None,
errors: str,
) -> str: ...
def _(
univeral_newlines: bool | None,
text: bool | None,
encoding: str | None,
errors: str | None,
) -> str | bytes: ...
class FileAndStringTypeToPipe(OverloadedType):
def _(
file: Literal[_PopenFileSpecial.PIPE],
string: str,
) -> IO[str]: ...
def _(
file: Literal[_PopenFileSpecial.PIPE],
string: bytes,
) -> IO[bytes]: ...
def _(
file: int | IO[Any] | None,
string: str | bytes,
) -> None: ...
class Popen[
AnyStr: (str, bytes),
StdinT: (IO[bytes], IO[str], None),
StdoutT: (IO[bytes], IO[str], None),
StderrT: (IO[bytes], IO[str], None),
]:
stdin: StdinT
stdout: StdoutT
stderr: StderrT
def __init__[
UniveralNewlinesT: (Literal[True], Literal[False], None),
TextT: (Literal[True], Literal[False], None),
EncodingT: (str, None),
ErrorsT: (str, None),
StdinArgT: (int | IO[Any] | None, Literal[PIPE]),
StdoutArgT: (int | IO[Any] | None, Literal[PIPE]),
StderrArgT: (int | IO[Any] | None, Literal[PIPE]),
](
self: Popen[
TextOptionsToStringType[UniveralNewlinesT, TextT, EncodingT, ErrorsT],
FileAndStringTypeToPipe[
StdinArgT,
TextOptionsToStringType[UniveralNewlinesT, TextT, EncodingT, ErrorsT],
],
FileAndStringTypeToPipe[
StdoutArgT,
TextOptionsToStringType[UniveralNewlinesT, TextT, EncodingT, ErrorsT],
],
FileAndStringTypeToPipe[
StderrArgT,
TextOptionsToStringType[UniveralNewlinesT, TextT, EncodingT, ErrorsT],
],
],
args: _CMD,
bufsize: int = -1,
executable: StrOrBytesPath | None = None,
stdin: StdinArgT = None,
stdout: StdoutArgT = None,
stderr: StderrArgT = None,
preexec_fn: Callable[[], Any] | None = None,
close_fds: bool = True,
shell: bool = False,
cwd: StrOrBytesPath | None = None,
env: _ENV | None = None,
universal_newlines: UniveralNewlinesT = None,
startupinfo: Any | None = None,
creationflags: int = 0,
restore_signals: bool = True,
start_new_session: bool = False,
pass_fds: Collection[int] = (),
*,
text: TextT = None,
encoding: EncodingT = None,
errors: ErrorsT = None,
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
pipesize: int = -1,
process_group: int | None = None,
) -> None: ...
Recursive Overloads
OverloadedTypes may be recursive (reference themselves in return-type position). Type checkers should implement reasonable recursion limits.
Recursive overloads are considered a very advanced feature, but can be used to express complex type transformations, such as on variadic or recursive types.
For example, this code defines an OverloadedType that maps a tuple of types to a tuple of Callables returning those types,
something not currently expressible (see this issue for more context):
from collections.abc import Callable
from typing import OverloadedType
class AppendToTuple(OverloadedType):
def _[*Ts, U](tup: tuple[*Ts], new: U) -> tuple[*Ts, U]: ...
class MapCallable(OverloadedType):
"""Transforms `tuple[T0, T1, ..., Tn]` into `tuple[Callable[[], T0], Callable[[], T1], ..., Callable[[], Tn]]`."""
# Initial
def _[*Ts](ts: tuple[*Ts]) -> MapCallable[tuple[*Ts], tuple[()]]: ...
# Terminal
def _[OutT](ts: tuple[()], out: OutT) -> OutT: ...
# Intermediate
def _[HeadT, *Ts, OutT: tuple](ts: tuple[HeadT, *Ts], out: OutT) -> MapCallable[
tuple[*Ts],
AppendToTuple[OutT, Callable[[], HeadT]],
]: ...
def make_callables[*Ts](*args: *Ts) -> MapCallable[tuple[*Ts]]:
return tuple((lambda: arg) for arg in args)
Runtime Behavior
At runtime, OverloadedTypes have a special __overloads__ attribute, whose value is a tuple containing the overload
methods defined in the class body, in the order they were defined.
In order to support this without requiring use of a redundant decorator, OverloadedType uses a custom metaclass, whose __prepare__
method returns a custom mapping that tracks assignments to _ and collects them under the __overloads__ key.
Backwards Compatibility
No breaking changes are introduced by this PEP. Existing code using typing.overload continues to function as before.
How to Teach This
Users should first be directed to familiarize themselves with typing.overload. Only then, OverloadedType should be taught as a sort
of advanced “typing-function”, matching and mapping input types to output types much in the same way as typing.overload.
Reference Implementation
[Link to any existing implementation and details about its state, e.g. proof-of-concept.]
Rejected Ideas
dict-based syntax
Earlier ideas revolved around a dict-based TypingMap or TypeMap special form. This was ultimately rejected for a few reasons:
- Using
TypeVars would require using pre-PEP 695 syntax, which was seen as a regression. - While no examples are currently known, a theoretical non-hashable type expression would not be usable as a key.
- Both the
*Mapterm and thedict-based syntax were seen as potentially confusing, as they may:- Imply some sort of runtime-lookup behavior, which is not the case.
- Imply some kind of
dict-like resolution semantics.
Open Issues
Output-to-input type inference
In some cases, it may be desirable to use OverloadedTypes in input positions, and have the type checker infer the input type(s):
def some_func[T](x: SomeOverloadedType[T]) -> T:
...
However, there are many cases where this is not practical or even possible. At this time, this PEP allows type checkers to support such inference when deemed feasible, but they may also choose to treat such cases as type errors. Future PEPs may explore this topic further.
Acknowledgements
[Thank anyone who has helped with the PEP.]
Footnotes
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.
Source: https://github.com/python/peps/blob/main/peps/pep-9999.rst
Last modified: 2026-01-12 09:17:36 GMT