Description
I'm opening this issue to discuss a typing feature supported by major type checkers (at least pyright and partially mypy), that allows users to annotate the self
argument of an __init__
method. This is currently used as a convenience feature (I haven't encountered any use case that would not be possible with the workaround described in the motivation section, but do let me know if you are aware of any), so the goal is to formally specify the behavior so that users can feel confident using it.
Annotating self
is already supported in some cases:
Already supported use cases
- Annotating
self
as a subclass:
from __future__ import annotations
from typing import overload
class A:
@overload
def __init__(self: B, a: int) -> None:
...
@overload
def __init__(self: C, a: str) -> None:
...
@overload
def __init__(self, a: bool) -> None:
...
class B(A):
pass
class C(A):
pass
In this case, instantiating B
should only match overload 1 and 3.
- Using
typing.Self
:
from typing import Self
class A:
def __init__(self: Self):
return
reveal_type(A())
- Before
typing.Self
was introduced, using aTypeVar
:
from typing import TypeVar
Self = TypeVar("Self", bound="A")
class A:
def __init__(self: Self, other: type[Self]):
return
reveal_type(A(A))
This issue proposes adding support for a special use case of the self
type annotation that would only apply when the annotation includes one or more type variables (class-scoped, method-scoped, or both), and would convey a special meaning for the type checker.
Motivation
This feature can be useful because the return type of __init__
is always None
, meaning there is no way to influence the parametrization of a instance when relying solely on the type checker solver is not enough. In most cases, the type variable can be inferred correctly without any explicit annotation:
from typing import Generic, TypeVar
T = TypeVar("T")
class Wrapper(Generic[T]):
def __init__(self, value: T) -> None:
self.value = value
reveal_type(Wrapper(1)) # Revealed type is "Wrapper[int]"
However, there are some situations where more complex logic is involved, e.g. by using the dynamic features of Python (metaclasses for instance). Consider this example:
class NullableWrapper(Generic[T]):
def __init__(self, value: T, null: bool = False) -> None:
self.value = value
# Some logic could make `value` as `None` depending on `null`
Ideally, we would like NullableWrapper(int, null=True)
to be inferred as NullableWrapper[int | None]
. A way to implement this is by making use of the __new__
method:
class NullableWrapper(Generic[T]):
@overload
def __new__(cls, value: V, null: Literal[True]) -> NullableWrapper[V | None]: ...
@overload
def __new__(cls, value: V, null: Literal[False] = ...) -> NullableWrapper[V]: ...
reveal_type(NullableWrapper(1, null=True)) # Type of "NullableWrapper(1)" is "NullableWrapper[int | None]"
However, this __new__
method might not exist at runtime, meaning users would have to add an if TYPE_CHECKING
block.
What the example above tries to convey is the fact that some constructs can't be reflected natively by type checkers. The example made use of a null
argument that should translate to None
in the resolved type variable, and here is a list of already existing examples:
- The stub definition of
dict
/collections.UserDict
: a lot of custom logic is applied to the provided arguments, and the type stubs definition is making use of this feature to cover the possible use cases. - As
mypy
doesn't support solving a type variable from a default value (see issue), overloads are used to explicitly specify the solved type from the default value: see the stub definition ofcontextlib.nullcontext
. - A similar example to one provided in this proposal: SQLAlchemy's
UUID
type: see the definition.
Specification
Definitions
- A "class-scoped" type variable refers to a
TypeVar
used in conjunction withGeneric
(or as specified with the new 3.12 syntax):
from typing import Generic, TypeVar
T = TypeVar("T")
# In this case, `T` is a class-scoped type variable
class Foo(Generic[T]): ...
- A "function-scoped" type variable refers to a
TypeVar
used in function / method:
from typing import TypeVar
T = TypeVar("T")
class Foo:
#In this case, `T` is a function-scoped type variable
def __init__(self, value: T) -> None: ...
Context
When instantiating a generic class, the user should generally explicitly specify the type(s) of the type variable(s), for example var: list[str] = list()
. However, type checkers can solve the class-scoped type variable(s) based on the arguments passed to the __init__
method, similarly to functions where type variables are involved. For instance:
from typing import Generic, TypeVar
T = TypeVar("T")
class Foo(Generic[T]):
def __init__(self, value: T) -> None: ...
reveal_type(Foo(1)) # Foo[int]
This proposal aims at standardizing the behavior when the self
argument of a generic class' __init__
method is annotated.
Canonical examples
Whenever a type checker encounters the __init__
method of a generic class where self
is explicitly annotated, it should use this type annotation as the single source of truth to solve the type variable(s) of that class. That includes the following examples:
from typing import Generic, TypeVar, overload
T = TypeVar("T")
class Foo(Generic[T]):
@overload
def __init__(self: Foo[int], value: int) -> None: ...
@overload
def __init__(self: Foo[str], value: str) -> None: ...
Currently supported by both mypy and pyright. ✅
from typing import Generic, TypeVar
T1 = TypeVar("T1")
T2 = TypeVar("T2")
V1 = TypeVar("V1")
V2 = TypeVar("V2")
class Foo(Generic[T1, T2]):
def __init__(self: Foo[V1, V2], value1: V1, value2: V2) -> None: ...
class Bar(Generic[T1, T2]):
def __init__(self: Bar[V2, V1], value1: V1, value2: V2) -> None: ...
reveal_type(Foo(1, "1")) # Foo[int, str]
reveal_type(Bar(1, "1")) # Bar[str, int]
Currently unsupported by both mypy and pyright. ❌
from typing import Generic, TypeVar
T1 = TypeVar("T1")
T2 = TypeVar("T2")
class Foo(Generic[T1, T2]):
def __init__(self: Foo[T1, T2], value1: T1, value2: T2) -> None: ...
class Bar(Generic[T1, T2]):
def __init__(self: Bar[T2, T1], value1: T1, value2: T2) -> None: ...
reveal_type(Foo(1, "1")) # Foo[int, str]
reveal_type(Bar(1, "1")) # Bar[str, int], Bar[int, str]?
Note
Although using class-scoped type variables to annotate self
is already quite common (see examples in motivation), we can see diverging behavior between mypy and pyright in the Bar
example. If the self
type annotation should be the only source of truth, then type checkers should infer Bar(1, "1")
as Bar[str, int]
, but this is open to discussion.
Behavior with subclasses
As stated in the motivation section, __new__
can be used as a workaround. However, it does not play well with subclasses (as expected):
from typing import Generic, TypeVar
T = TypeVar("T")
V = TypeVar("V")
class Foo(Generic[T]):
def __new__(cls, value: V) -> Foo[V]: ...
class SubFoo(Foo[T]):
pass
reveal_type(SubFoo(1)) # Type of "SubFoo(1)" is "Foo[int]"
The same would look like this with __init__
:
from typing import Generic, TypeVar
T = TypeVar("T")
V = TypeVar("V")
class Foo(Generic[T]):
def __init__(self: Foo[V], value: V) -> None: ...
class SubFoo(Foo[T]): ...
reveal_type(SubFoo(1))
As with __new__
, subclasses shouldn't be supported in this case (i.e. reveal_type(SubFoo(1))
shouldn't be SubFoo[int]
).
Note
I think shouldn't be supported should mean undefined behavior in this case, although this can be discussed. While the given example does not show any issues as to why it shouldn't be supported, consider the following example:
class OtherSub(Foo[str]): ...
OtherSub(1) # What should happen here? is it `OtherSub[str]`, `OtherSub[int]`?
# This can also be problematic if multiple type variables
# are involved in the parent class and the subclass explicitly solves
# some of them (`Sub(Base[int, T1, T2]` for instance).
However, this is open to discussion if you think type checkers could handle these specific scenarios.
Appendix - Invalid use cases
For reference, here are some invalid use cases that are not necessarily related to the proposed feature:
Using an unrelated class as a type annotation
from __future__ import annotations
from typing import Generic, Literal, TypeVar, overload, reveal_type
T = TypeVar("T")
class Unrelated:
pass
class A(Generic[T]):
@overload
def __init__(self: Unrelated, is_int: Literal[True]) -> None:
...
@overload
def __init__(self: A[str], is_int: Literal[False] = ...) -> None:
...
def __init__(self, is_int: bool = False) -> None:
...
reveal_type(A(is_int=True))
Both type checkers raise an error, but a different one (mypy explicitly disallows the annotated self
in the first overload, pyright doesn't raise an error but instead discards the first overload, meaning True
can't be used for is_int
).
Using a supertype as a type annotation
from __future__ import annotations
from typing import Generic, Literal, TypeVar, overload, reveal_type
T = TypeVar("T")
class Super(Generic[T]):
pass
class A(Super[T]):
@overload
def __init__(self: Super[int], is_int: Literal[True]) -> None:
...
@overload
def __init__(self: Super[str], is_int: Literal[False] = ...) -> None:
...
def __init__(self, is_int: bool = False) -> None:
...
reveal_type(A(is_int=True))
No error on both type checkers, but they both infer A[Unknown/Never]
. I don't see any use case where this could be allowed? Probably better suited for __new__
.