Back to Ruff

PEP 695 `ParamSpec`

crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md

0.15.1237.2 KB
Original Source

PEP 695 ParamSpec

ParamSpec was introduced in Python 3.12 while the support for specifying defaults was added in Python 3.13.

toml
[environment]
python-version = "3.13"

Definition

py
def foo1[**P]() -> None:
    reveal_type(P)  # revealed: ParamSpec

Bounds and constraints

ParamSpec, when defined using the new syntax, does not allow defining bounds or constraints.

TODO: This results in a lot of syntax errors mainly because the AST doesn't accept them in this position. The parser could do a better job in recovering from these errors.

<!-- fmt:off -->
py
# error: [invalid-syntax]
# error: [invalid-syntax]
# error: [invalid-syntax]
# error: [invalid-syntax]
# error: [invalid-syntax]
# error: [invalid-syntax]
def foo[**P: int]() -> None:
    # error: [invalid-syntax]
    # error: [invalid-syntax]
    pass
<!-- fmt:on -->

Default

The default value for a ParamSpec can be either a list of types, ..., or another ParamSpec.

py
def foo2[**P = ...]() -> None:
    reveal_type(P)  # revealed: ParamSpec

def foo3[**P = [int, str]]() -> None:
    reveal_type(P)  # revealed: ParamSpec

def foo4[**P, **Q = P]():
    reveal_type(P)  # revealed: ParamSpec
    reveal_type(Q)  # revealed: ParamSpec

# error: [invalid-type-form] "Bare ParamSpec `Q` is not valid in this context"
def foo5[**Q, **P = [Q]]() -> None:
    pass

Other values are invalid.

py
# error: [invalid-paramspec]
def foo[**P = int]() -> None:
    pass

Validating ParamSpec usage

ParamSpec is only valid as the first element to Callable or the final element to Concatenate.

<!-- snapshot-diagnostics -->
py
from typing import Any, Final, ParamSpec, Callable, Concatenate, Union, Optional, Annotated

def valid[**P](
    a1: Callable[P, int],
    a2: Callable[Concatenate[int, P], int],
    a3: Callable["P", int],
    a4: Callable[Concatenate[int, "P"], int],
) -> None: ...
def invalid[**P](
    # error: [invalid-type-form] "Bare ParamSpec `P` is not valid in this context"
    a1: P,
    # error: [invalid-type-form] "Bare ParamSpec `P` is not valid in this context"
    a3: Callable[[P], int],
    # error: [invalid-type-form] "Bare ParamSpec `P` is not valid in this context"
    a4: Callable[..., P],
    # error: [invalid-type-form] "Bare ParamSpec `P` is not valid in this context"
    a5: Callable[Concatenate[P, ...], int],
    # error: [invalid-type-form] "Bare ParamSpec `P` is not valid in this context"
    a6: P | int,
    # error: [invalid-type-form] "Bare ParamSpec `P` is not valid in this context"
    a7: Union[P, int],
    # error: [invalid-type-form] "Bare ParamSpec `P` is not valid in this context"
    a8: Optional[P],
    # error: [invalid-type-form] "Bare ParamSpec `P` is not valid in this context"
    a9: Annotated[P, "metadata"],
) -> None: ...

# error: [invalid-type-form] "Bare ParamSpec `P` is not valid in this context"
def invalid_return[**P]() -> P:
    raise NotImplementedError

# error: [invalid-type-form] "Bare ParamSpec `P` is not valid in this context"
type Alias[**P] = P

def invalid_variable_annotation[**P](y: Any) -> None:
    # error: [invalid-type-form] "Bare ParamSpec `P` is not valid in this context"
    x: P = y

def invalid_with_qualifier[**P](y: Any) -> None:
    # error: [invalid-type-form] "Bare ParamSpec `P` is not valid in this context"
    x: Final[P] = y

# error: [invalid-type-form] "Bare ParamSpec `P` is not valid in this context"
def invalid_stringified_return[**P]() -> "P":
    raise NotImplementedError

def invalid_stringified_annotation[**P](
    # error: [invalid-type-form] "Bare ParamSpec `P` is not valid in this context"
    a: "P",
) -> None: ...
def invalid_stringified_variable_annotation[**P](y: Any) -> None:
    # error: [invalid-type-form] "Bare ParamSpec `P` is not valid in this context"
    x: "P" = y

class InvalidSpecializationTarget[**P]:
    attr: Callable[P, None]

def invalid_specialization[**Q](
    # error: [invalid-type-form] "Bare ParamSpec `Q` is not valid in this context"
    a: InvalidSpecializationTarget[[Q]],
    # error: [invalid-type-form] "Bare ParamSpec `Q` is not valid in this context"
    b: InvalidSpecializationTarget[Q,],
) -> None: ...

Validating P.args and P.kwargs usage

The components of ParamSpec i.e., P.args and P.kwargs are only valid when used as the annotated types of *args and **kwargs respectively.

py
from typing import Callable

def foo[**P](c: Callable[P, int]) -> None:
    def nested1(*args: P.args, **kwargs: P.kwargs) -> None: ...

    # error: [invalid-type-form] "`P.kwargs` is valid only in `**kwargs` annotation: Did you mean `P.args`?"
    # error: [invalid-type-form] "`P.args` is valid only in `*args` annotation: Did you mean `P.kwargs`?"
    def nested2(*args: P.kwargs, **kwargs: P.args) -> None: ...

    # error: [invalid-paramspec] "`*args: P.args` must be accompanied by `**kwargs: P.kwargs`"
    def nested3(*args: P.args) -> None: ...

    # error: [invalid-paramspec] "`**kwargs: P.kwargs` must be accompanied by `*args: P.args`"
    def nested4(**kwargs: P.kwargs) -> None: ...

    # error: [invalid-paramspec] "No parameters may appear between `*args: P.args` and `**kwargs: P.kwargs`"
    def nested5(*args: P.args, x: int, **kwargs: P.kwargs) -> None: ...

    # error: [invalid-paramspec] "`P.args` is only valid for annotating `*args`"
    def nested6(x: P.args) -> None: ...
    def nested7(
        *args: P.args,
        # error: [invalid-paramspec] "`*args: P.args` must be accompanied by `**kwargs: P.kwargs`"
        **kwargs: int,
    ) -> None: ...

And, they need to be used together.

py
def foo[**P](c: Callable[P, int]) -> None:
    # error: [invalid-paramspec] "`*args: P.args` must be accompanied by `**kwargs: P.kwargs`"
    def nested1(*args: P.args) -> None: ...

    # error: [invalid-paramspec] "`**kwargs: P.kwargs` must be accompanied by `*args: P.args`"
    def nested2(**kwargs: P.kwargs) -> None: ...

class Foo[**P]:
    # error: [invalid-paramspec] "`P.args` is only valid for annotating `*args` function parameters"
    args: P.args

    # error: [invalid-paramspec] "`P.kwargs` is only valid for annotating `**kwargs` function parameters"
    kwargs: P.kwargs

The name of these parameters does not need to be args or kwargs, it's the annotated type to the respective variadic parameter that matters.

py
class Foo3[**P]:
    def method1(self, *paramspec_args: P.args, **paramspec_kwargs: P.kwargs) -> None: ...
    def method2(
        self,
        # error: [invalid-type-form] "`P.kwargs` is valid only in `**kwargs` annotation: Did you mean `P.args`?"
        *paramspec_args: P.kwargs,
        # error: [invalid-type-form] "`P.args` is valid only in `*args` annotation: Did you mean `P.kwargs`?"
        **paramspec_kwargs: P.args,
    ) -> None: ...

Error messages for invalid-paramspec also use the actual parameter names:

py
def bar[**P](c: Callable[P, int]) -> None:
    # error: [invalid-paramspec] "`*my_args: P.args` must be accompanied by `**my_kwargs: P.kwargs`"
    def f1(*my_args: P.args, **my_kwargs: int) -> None: ...

    # error: [invalid-paramspec] "`*positional: P.args` must be accompanied by `**kwargs: P.kwargs`"
    def f2(*positional: P.args) -> None: ...

    # error: [invalid-paramspec] "`**keyword: P.kwargs` must be accompanied by `*args: P.args`"
    def f3(**keyword: P.kwargs) -> None: ...

    # error: [invalid-paramspec] "No parameters may appear between `*a: P.args` and `**kw: P.kwargs`"
    def f4(*a: P.args, x: int, **kw: P.kwargs) -> None: ...

It isn't allowed to annotate an instance attribute either:

py
class Foo4[**P]:
    def __init__(self, fn: Callable[P, int], *args: P.args, **kwargs: P.kwargs) -> None:
        self.fn = fn
        # error: [invalid-paramspec] "`P.args` is only valid for annotating `*args` function parameters"
        self.args: P.args = args
        # error: [invalid-paramspec] "`P.kwargs` is only valid for annotating `**kwargs` function parameters"
        self.kwargs: P.kwargs = kwargs

Semantics of P.args and P.kwargs

The type of args and kwargs inside the function is P.args and P.kwargs respectively instead of tuple[P.args, ...] and dict[str, P.kwargs].

Passing *args and **kwargs to a callable

py
from typing import Callable

def f[**P](func: Callable[P, int]) -> Callable[P, None]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
        reveal_type(args)  # revealed: [email protected]
        reveal_type(kwargs)  # revealed: [email protected]
        reveal_type(func(*args, **kwargs))  # revealed: int

        # error: [invalid-argument-type] "Argument is incorrect: Expected `[email protected]`, found `[email protected]`"
        # error: [invalid-argument-type] "Argument is incorrect: Expected `[email protected]`, found `[email protected]`"
        reveal_type(func(*kwargs, **args))  # revealed: int

        # error: [invalid-argument-type] "Argument is incorrect: Expected `[email protected]`, found `[email protected]`"
        # error: [missing-argument]
        reveal_type(func(args, kwargs))  # revealed: int

        # Both parameters are required
        # error: [missing-argument]
        reveal_type(func())  # revealed: int
        # error: [missing-argument]
        reveal_type(func(*args))  # revealed: int
        # error: [missing-argument]
        reveal_type(func(**kwargs))  # revealed: int
    return wrapper

Operations on P.args and P.kwargs

The type of P.args and P.kwargs behave like a tuple and dict respectively. Internally, they are represented as a type variable that has an upper bound of tuple[object, ...] and Top[dict[str, Any]] respectively.

py
from typing import Callable, Any

def f[**P](func: Callable[P, int], *args: P.args, **kwargs: P.kwargs) -> None:
    reveal_type(args + ("extra",))  # revealed: tuple[object, ...]
    reveal_type(args + (1, 2, 3))  # revealed: tuple[object, ...]
    reveal_type(args[0])  # revealed: object

    reveal_type("key" in kwargs)  # revealed: bool
    reveal_type(kwargs.get("key"))  # revealed: object
    reveal_type(kwargs["key"])  # revealed: object

Specializing generic classes explicitly

py
from typing import Any, Callable, ParamSpec

class OnlyParamSpec[**P1]:
    attr: Callable[P1, None]

class TwoParamSpec[**P1, **P2]:
    attr1: Callable[P1, None]
    attr2: Callable[P2, None]

class TypeVarAndParamSpec[T1, **P1]:
    attr: Callable[P1, T1]

Explicit specialization of a generic class involving ParamSpec is done by providing either a list of types, ..., or another in-scope ParamSpec.

py
reveal_type(OnlyParamSpec[[]]().attr)  # revealed: () -> None
reveal_type(OnlyParamSpec[[int, str]]().attr)  # revealed: (int, str, /) -> None
reveal_type(OnlyParamSpec[...]().attr)  # revealed: (...) -> None

def func[**P2](c: Callable[P2, None]):
    reveal_type(OnlyParamSpec[P2]().attr)  # revealed: (**P2@func) -> None

P2 = ParamSpec("P2")

# error: [invalid-type-arguments] "ParamSpec `P2` is unbound"
reveal_type(OnlyParamSpec[P2]().attr)  # revealed: (...) -> None

# error: [invalid-type-arguments] "No type argument provided for required type variable `P1` of class `OnlyParamSpec`"
reveal_type(OnlyParamSpec[()]().attr)  # revealed: (...) -> None

An explicit tuple expression (unlike an implicit one that omits the parentheses) is also accepted when the ParamSpec is the only type variable. But, this isn't recommended is mainly a fallout of it having the same AST as the one without the parentheses. Both mypy and Pyright also allow this.

py
reveal_type(OnlyParamSpec[(int, str)]().attr)  # revealed: (int, str, /) -> None
<!-- fmt:off -->
py
# error: [invalid-syntax]
reveal_type(OnlyParamSpec[]().attr)  # revealed: (...) -> None
<!-- fmt:on -->

The square brackets can be omitted when ParamSpec is the only type variable

py
reveal_type(OnlyParamSpec[int, str]().attr)  # revealed: (int, str, /) -> None
reveal_type(OnlyParamSpec[int,]().attr)  # revealed: (int, /) -> None

# Even when there is only one element
reveal_type(OnlyParamSpec[Any]().attr)  # revealed: (Any, /) -> None
reveal_type(OnlyParamSpec[object]().attr)  # revealed: (object, /) -> None
reveal_type(OnlyParamSpec[int]().attr)  # revealed: (int, /) -> None

But, they cannot be omitted when there are multiple type variables.

py
reveal_type(TypeVarAndParamSpec[int, []]().attr)  # revealed: () -> int
reveal_type(TypeVarAndParamSpec[int, [int, str]]().attr)  # revealed: (int, str, /) -> int
reveal_type(TypeVarAndParamSpec[int, [str]]().attr)  # revealed: (str, /) -> int
reveal_type(TypeVarAndParamSpec[int, ...]().attr)  # revealed: (...) -> int

# error: [invalid-type-arguments] "ParamSpec `P2` is unbound"
reveal_type(TypeVarAndParamSpec[int, P2]().attr)  # revealed: (...) -> int
# error: [invalid-type-arguments] "Type argument for `ParamSpec` must be"
reveal_type(TypeVarAndParamSpec[int, int]().attr)  # revealed: (...) -> int
# error: [invalid-type-arguments] "Type argument for `ParamSpec` must be"
reveal_type(TypeVarAndParamSpec[int, ()]().attr)  # revealed: (...) -> int
# error: [invalid-type-arguments] "Type argument for `ParamSpec` must be"
reveal_type(TypeVarAndParamSpec[int, (int, str)]().attr)  # revealed: (...) -> int

Nor can they be omitted when there are more than one ParamSpec.

py
p = TwoParamSpec[[int, str], [int]]()
reveal_type(p.attr1)  # revealed: (int, str, /) -> None
reveal_type(p.attr2)  # revealed: (int, /) -> None

# error: [invalid-type-arguments] "Type argument for `ParamSpec` must be either a list of types, `ParamSpec`, `Concatenate`, or `...`"
# error: [invalid-type-arguments] "Type argument for `ParamSpec` must be either a list of types, `ParamSpec`, `Concatenate`, or `...`"
TwoParamSpec[int, str]

Specializing ParamSpec type variable using typing.Any isn't explicitly allowed by the spec but both mypy and Pyright allow this and there are usages of this in the wild e.g., staticmethod[Any, Any].

py
reveal_type(TypeVarAndParamSpec[int, Any]().attr)  # revealed: (...) -> int

ParamSpec cannot specialize a TypeVar, and vice versa

<!-- snapshot-diagnostics -->

A ParamSpec is not a valid type argument for a regular TypeVar, and vice versa.

py
from typing import Callable

class OnlyTypeVar[T]:
    attr: T

class TypeVarAndParamSpec[T, **P]:
    attr: Callable[P, T]

def f[**P, T]():
    # error: [invalid-type-arguments] "ParamSpec `P` cannot be used to specialize type variable `T`"
    a: OnlyTypeVar[P]

    # error: [invalid-type-arguments] "ParamSpec `P` cannot be used to specialize type variable `T`"
    b: TypeVarAndParamSpec[P, [int]]

class OnlyParamSpec[**P]:
    attr: Callable[P, None]

# This is fine due to the special case whereby `OnlyParamSpec[T]` is interpreted the same as
# `OnlyParamSpec[[T]]`, due to the fact that `OnlyParamSpec` is only generic over a single
# `ParamSpec` and no other type variables.
def func2[T](c: OnlyParamSpec[T], other: T):
    reveal_type(c.attr)  # revealed: (T@func2, /) -> None

class ParamSpecAndTypeVar[**P, T]:
    attr: Callable[P, T]

# error: [invalid-type-arguments] "Type argument for `ParamSpec` must be either a list of types, `ParamSpec`, `Concatenate`, or `...`"
def func3[T](c: ParamSpecAndTypeVar[T, int], other: T): ...

Specialization when defaults are involved

py
from typing import Callable, ParamSpec

class ParamSpecWithDefault1[**P1 = [int, str]]:
    attr: Callable[P1, None]

reveal_type(ParamSpecWithDefault1().attr)  # revealed: (int, str, /) -> None
reveal_type(ParamSpecWithDefault1[int]().attr)  # revealed: (int, /) -> None
py
class ParamSpecWithDefault2[**P1 = ...]:
    attr: Callable[P1, None]

reveal_type(ParamSpecWithDefault2().attr)  # revealed: (...) -> None
reveal_type(ParamSpecWithDefault2[int, str]().attr)  # revealed: (int, str, /) -> None
py
class ParamSpecWithDefault3[**P1, **P2 = P1]:
    attr1: Callable[P1, None]
    attr2: Callable[P2, None]

# `P1` hasn't been specialized, so it defaults to `...` gradual form
p1 = ParamSpecWithDefault3()
reveal_type(p1.attr1)  # revealed: (...) -> None
reveal_type(p1.attr2)  # revealed: (...) -> None

p2 = ParamSpecWithDefault3[[int, str]]()
reveal_type(p2.attr1)  # revealed: (int, str, /) -> None
reveal_type(p2.attr2)  # revealed: (int, str, /) -> None

p3 = ParamSpecWithDefault3[[int], [str]]()
reveal_type(p3.attr1)  # revealed: (int, /) -> None
reveal_type(p3.attr2)  # revealed: (str, /) -> None

class ParamSpecWithDefault4[**P1 = [int, str], **P2 = P1]:
    attr1: Callable[P1, None]
    attr2: Callable[P2, None]

p1 = ParamSpecWithDefault4()
reveal_type(p1.attr1)  # revealed: (int, str, /) -> None
reveal_type(p1.attr2)  # revealed: (int, str, /) -> None

p2 = ParamSpecWithDefault4[[int]]()
reveal_type(p2.attr1)  # revealed: (int, /) -> None
reveal_type(p2.attr2)  # revealed: (int, /) -> None

p3 = ParamSpecWithDefault4[[int], [str]]()
reveal_type(p3.attr1)  # revealed: (int, /) -> None
reveal_type(p3.attr2)  # revealed: (str, /) -> None

P2 = ParamSpec("P2")

# error: [invalid-generic-class] "Default of `P1` cannot reference out-of-scope type variable `P2`"
class ParamSpecWithDefault5[**P1 = P2]:
    attr: Callable[P1, None]

Semantics

Most of these test cases are adopted from the typing documentation on ParamSpec semantics.

Return type change using ParamSpec once

py
from typing import Callable

def converter[**P](func: Callable[P, int]) -> Callable[P, bool]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> bool:
        func(*args, **kwargs)
        return True
    return wrapper

def f1(x: int, y: str) -> int:
    return 1

# This should preserve all the information about the parameters of `f1`
f2 = converter(f1)

reveal_type(f2)  # revealed: (x: int, y: str) -> bool

reveal_type(f1(1, "a"))  # revealed: int
reveal_type(f2(1, "a"))  # revealed: bool

# As it preserves the parameter kinds, the following should work as well
reveal_type(f2(1, y="a"))  # revealed: bool
reveal_type(f2(x=1, y="a"))  # revealed: bool
reveal_type(f2(y="a", x=1))  # revealed: bool

# error: [missing-argument] "No argument provided for required parameter `y`"
f2(1)
# error: [invalid-argument-type] "Argument is incorrect: Expected `int`, found `Literal["a"]`"
f2("a", "b")

The converter function act as a decorator here:

py
@converter
def f3(x: int, y: str) -> int:
    return 1

reveal_type(f3)  # revealed: (x: int, y: str) -> bool

reveal_type(f3(1, "a"))  # revealed: bool
reveal_type(f3(x=1, y="a"))  # revealed: bool
reveal_type(f3(1, y="a"))  # revealed: bool
reveal_type(f3(y="a", x=1))  # revealed: bool

# error: [missing-argument] "No argument provided for required parameter `y`"
f3(1)
# error: [invalid-argument-type] "Argument is incorrect: Expected `int`, found `Literal["a"]`"
f3("a", "b")

Return type change using the same ParamSpec multiple times

py
from typing import Callable

def multiple[**P](func1: Callable[P, int], func2: Callable[P, int]) -> Callable[P, bool]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> bool:
        func1(*args, **kwargs)
        func2(*args, **kwargs)
        return True
    return wrapper

As per the spec,

A user may include the same ParamSpec multiple times in the arguments of the same function, to indicate a dependency between multiple arguments. In these cases a type checker may choose to solve to a common behavioral supertype (i.e. a set of parameters for which all of the valid calls are valid in both of the subtypes), but is not obligated to do so.

TODO: Currently, we don't do this

py
def xy(x: int, y: str) -> int:
    return 1

def yx(y: int, x: str) -> int:
    return 2

reveal_type(multiple(xy, xy))  # revealed: (x: int, y: str) -> bool

# The common supertype is `(int, str, /)` which is converting the positional-or-keyword parameters
# into positional-only parameters because the position of the types are the same.
# TODO: This shouldn't error
# error: [invalid-argument-type]
reveal_type(multiple(xy, yx))  # revealed: (x: int, y: str) -> bool

def keyword_only_with_default_1(*, x: int = 42) -> int:
    return 1

def keyword_only_with_default_2(*, y: int = 42) -> int:
    return 2

# The common supertype for two functions with only keyword-only parameters would be an empty
# parameter list i.e., `()`
# TODO: This shouldn't error
# error: [invalid-argument-type]
# revealed: (*, x: int = 42) -> bool
reveal_type(multiple(keyword_only_with_default_1, keyword_only_with_default_2))

def keyword_only1(*, x: int) -> int:
    return 1

def keyword_only2(*, y: int) -> int:
    return 2

# On the other hand, combining two functions with only keyword-only parameters does not have a
# common supertype, so it should result in an error.
# error: [invalid-argument-type] "Argument to function `multiple` is incorrect: Expected `(*, x: int) -> int`, found `def keyword_only2(*, y: int) -> int`"
reveal_type(multiple(keyword_only1, keyword_only2))  # revealed: (*, x: int) -> bool

Constructors of user-defined generic class on ParamSpec

py
from typing import Callable

class C[**P]:
    f: Callable[P, int]

    def __init__(self, f: Callable[P, int]) -> None:
        self.f = f

# Note that the return type must match exactly, since C is invariant on the return type of C.f.
def f(x: int, y: str) -> int:
    return True

c = C(f)
reveal_type(c.f)  # revealed: (x: int, y: str) -> int

ParamSpec in prepended positional parameters

If one of these prepended positional parameters contains a free ParamSpec, we consider that variable in scope for the purposes of extracting the components of that ParamSpec.

py
from typing import Callable

def foo1[**P1](func: Callable[P1, int], *args: P1.args, **kwargs: P1.kwargs) -> int:
    return func(*args, **kwargs)

def foo1_with_extra_arg[**P1](func: Callable[P1, int], extra: str, *args: P1.args, **kwargs: P1.kwargs) -> int:
    return func(*args, **kwargs)

def foo2[**P2](func: Callable[P2, int], *args: P2.args, **kwargs: P2.kwargs) -> None:
    foo1(func, *args, **kwargs)

    # error: [invalid-argument-type] "Argument to function `foo1` is incorrect: Expected `[email protected]`, found `Literal[1]`"
    foo1(func, 1, *args, **kwargs)

    # error: [invalid-argument-type] "Argument to function `foo1_with_extra_arg` is incorrect: Expected `str`, found `[email protected]`"
    foo1_with_extra_arg(func, *args, **kwargs)

    foo1_with_extra_arg(func, "extra", *args, **kwargs)

Here, the first argument to f can specialize P to the parameters of the callable passed to it which is then used to type the ParamSpec components used in *args and **kwargs.

py
def f1(x: int, y: str) -> int:
    return 1

foo1(f1, 1, "a")
foo1(f1, x=1, y="a")
foo1(f1, 1, y="a")

# error: [missing-argument] "No arguments provided for required parameters `x`, `y` of function `foo1`"
foo1(f1)

# error: [missing-argument] "No argument provided for required parameter `y` of function `foo1`"
foo1(f1, 1)

# error: [invalid-argument-type] "Argument to function `foo1` is incorrect: Expected `str`, found `Literal[2]`"
foo1(f1, 1, 2)

# error: [too-many-positional-arguments] "Too many positional arguments to function `foo1`: expected 2, got 3"
foo1(f1, 1, "a", "b")

# error: [missing-argument] "No argument provided for required parameter `y` of function `foo1`"
# error: [unknown-argument] "Argument `z` does not match any known parameter of function `foo1`"
foo1(f1, x=1, z="a")

Specializing ParamSpec with another ParamSpec

py
class Foo[**P]:
    def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None:
        self.args = args
        self.kwargs = kwargs

def bar[**P](foo: Foo[P]) -> None:
    reveal_type(foo)  # revealed: Foo[P@bar]
    reveal_type(foo.args)  # revealed: [email protected]
    reveal_type(foo.kwargs)  # revealed: [email protected]

ty will check whether the argument after ** is a mapping type, but the inferred attribute type preserves the parameter pack here, so it shouldn't error.

py
from typing import Callable

def baz[**P](fn: Callable[P, None], foo: Foo[P]) -> None:
    fn(*foo.args, **foo.kwargs)

The Unknown can be eliminated by using annotating these attributes with Final:

py
from typing import Final

class FooWithFinal[**P]:
    def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None:
        self.args: Final = args
        self.kwargs: Final = kwargs

def with_final[**P](foo: FooWithFinal[P]) -> None:
    reveal_type(foo)  # revealed: FooWithFinal[P@with_final]
    reveal_type(foo.args)  # revealed: P@with_final.args
    reveal_type(foo.kwargs)  # revealed: P@with_final.kwargs

Specializing Self when ParamSpec is involved

py
class Foo[**P]:
    def method(self, *args: P.args, **kwargs: P.kwargs) -> str:
        return "hello"

foo = Foo[int, str]()

reveal_type(foo)  # revealed: Foo[(int, str, /)]
reveal_type(foo.method)  # revealed: bound method Foo[(int, str, /)].method(int, str, /) -> str
reveal_type(foo.method(1, "a"))  # revealed: str

Gradual types propagate through ParamSpec inference

py
from typing import Callable

def callable_identity[**P, R](func: Callable[P, R]) -> Callable[P, R]:
    return func

@callable_identity
def f(env: dict) -> None:
    pass

# revealed: (env: dict[Unknown, Unknown]) -> None
reveal_type(f)

Overloads

overloaded.pyi:

pyi
from typing import overload

@overload
def int_int(x: int) -> int: ...
@overload
def int_int(x: str) -> int: ...
@overload
def int_str(x: int) -> int: ...
@overload
def int_str(x: str) -> str: ...
@overload
def str_str(x: int) -> str: ...
@overload
def str_str(x: str) -> str: ...
py
from typing import Callable
from overloaded import int_int, int_str, str_str

def change_return_type[**P](f: Callable[P, int]) -> Callable[P, str]:
    def nested(*args: P.args, **kwargs: P.kwargs) -> str:
        return str(f(*args, **kwargs))
    return nested

def with_parameters[**P](f: Callable[P, int], *args: P.args, **kwargs: P.kwargs) -> Callable[P, str]:
    def nested(*args: P.args, **kwargs: P.kwargs) -> str:
        return str(f(*args, **kwargs))
    return nested

reveal_type(change_return_type(int_int))  # revealed: Overload[(x: int) -> str, (x: str) -> str]

# TODO: This shouldn't error and should pick the first overload because of the return type
# error: [invalid-argument-type]
reveal_type(change_return_type(int_str))  # revealed: Overload[(x: int) -> str, (x: str) -> str]

# error: [invalid-argument-type]
reveal_type(change_return_type(str_str))  # revealed: (...) -> str

# TODO: This should reveal the matching overload instead
reveal_type(with_parameters(int_int, 1))  # revealed: Overload[(x: int) -> str, (x: str) -> str]
reveal_type(with_parameters(int_int, "a"))  # revealed: Overload[(x: int) -> str, (x: str) -> str]

# error: [invalid-argument-type] "Argument to function `with_parameters` is incorrect: Expected `int`, found `None`"
reveal_type(with_parameters(int_int, None))  # revealed: Overload[(x: int) -> str, (x: str) -> str]

def foo(int_or_str: int | str):
    # Argument type expansion leads to matching both overloads.
    # TODO: Should this be an error instead?
    reveal_type(with_parameters(int_int, int_or_str))  # revealed: Overload[(x: int) -> str, (x: str) -> str]

# Keyword argument matching should also work
# TODO: This should reveal the matching overload instead
reveal_type(with_parameters(int_int, x=1))  # revealed: Overload[(x: int) -> str, (x: str) -> str]
reveal_type(with_parameters(int_int, x="a"))  # revealed: Overload[(x: int) -> str, (x: str) -> str]

# No matching overload should error
# error: [invalid-argument-type]
reveal_type(with_parameters(int_int, 1.5))  # revealed: Overload[(x: int) -> str, (x: str) -> str]

Overloads with multiple parameters

overloaded.pyi:

pyi
from typing import overload

@overload
def multi(x: int, y: int) -> int: ...
@overload
def multi(x: str, y: str) -> str: ...
py
from typing import Callable
from overloaded import multi

def run[**P, R](f: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
    return f(*args, **kwargs)

# Both arguments match first overload
# TODO: should reveal `int`
reveal_type(run(multi, 1, 2))  # revealed: int | str

# Both arguments match second overload
# TODO: should reveal `str`
reveal_type(run(multi, "a", "b"))  # revealed: int | str

# Mixed positional and keyword
# TODO: both should reveal `int`
reveal_type(run(multi, 1, y=2))  # revealed: int | str
reveal_type(run(multi, x=1, y=2))  # revealed: int | str

# No matching overload (int, str doesn't match either overload of `multi`)
# error: [invalid-argument-type]
reveal_type(run(multi, 1, "b"))  # revealed: int | str

Overloads with subtitution of P.args and P.kwargs

This is regression test for https://github.com/astral-sh/ty/issues/2027

py
from typing import Callable, Never, overload

class Task[**P, R]:
    def __init__(self, func: Callable[P, R]) -> None:
        self.func = func

    @overload
    def __call__(self: "Task[P, R]", *args: P.args, **kwargs: P.kwargs) -> R: ...
    @overload
    def __call__(self: "Task[P, Never]", *args: P.args, **kwargs: P.kwargs) -> None: ...
    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R | None:
        return self.func(*args, **kwargs)

def returns_str(x: int) -> str:
    return str(x)

def never_returns(x: int) -> Never:
    raise Exception()

t1 = Task(returns_str)
reveal_type(t1)  # revealed: Task[(x: int), str]
reveal_type(t1(1))  # revealed: str
reveal_type(t1(x=1))  # revealed: str
# error: [no-matching-overload]
reveal_type(t1("a"))  # revealed: Unknown
# error: [no-matching-overload]
reveal_type(t1(y=1))  # revealed: Unknown

t2 = Task(never_returns)
# TODO: This should be `Task[(x: int), Never]`
reveal_type(t2)  # revealed: Task[(x: int), Unknown]
# TODO: This should be `Never`
reveal_type(t2(1))  # revealed: Unknown

ParamSpec attribute assignability

When comparing signatures with ParamSpec attributes (P.args and P.kwargs), two different inferable ParamSpec attributes with the same kind are assignable to each other. This enables method overrides where both methods have their own ParamSpec.

Same attribute kind, both inferable

py
from typing import Callable

class Parent:
    def method[**P](self, callback: Callable[P, None]) -> Callable[P, None]:
        return callback

class Child1(Parent):
    # This is a valid override: Q.args matches P.args, Q.kwargs matches P.kwargs
    def method[**Q](self, callback: Callable[Q, None]) -> Callable[Q, None]:
        return callback

# Both signatures use ParamSpec, so they should be compatible
def outer[**P](f: Callable[P, int]) -> Callable[P, int]:
    def inner[**Q](g: Callable[Q, int]) -> Callable[Q, int]:
        return g
    return inner(f)

We can explicitly mark it as an override using the @override decorator.

py
from typing import override

class Child2(Parent):
    @override
    def method[**Q](self, callback: Callable[Q, None]) -> Callable[Q, None]:
        return callback

One ParamSpec not inferable

Here, P is in a non-inferable position while Q is inferable. So, they are not considered assignable.

py
from typing import Callable

class Container[**P]:
    def method(self, f: Callable[P, None]) -> Callable[P, None]:
        return f

    def try_assign[**Q](self, f: Callable[Q, None]) -> Callable[Q, None]:
        # error: [invalid-return-type] "Return type does not match returned value: expected `(**Q@try_assign) -> None`, found `(**P@Container) -> None`"
        # error: [invalid-argument-type] "Argument to bound method `Container.method` is incorrect: Expected `(**P@Container) -> None`, found `(**Q@try_assign) -> None`"
        return self.method(f)

ParamSpec inference with un-annotated return type

Regression test for an issue where ParamSpec inference failed when the callable we were inferring from did not have an annotated return type.

py
from typing import Callable

def infer_paramspec[**P](func: Callable[P, None]) -> Callable[P, None]:
    return func

def f(x: int, y: str):
    pass

reveal_type(infer_paramspec(f))  # revealed: (x: int, y: str) -> None

Generic context preservation through ParamSpec decorators

When a generic function is decorated with a ParamSpec-based decorator, the generic context of the decorated function should be preserved. This allows type inference to work correctly when calling the decorated function.

Regression test for https://github.com/astral-sh/ty/issues/2336

Basic

py
from typing import Callable
from ty_extensions import generic_context

def decorator[**P, T](func: Callable[P, T]) -> Callable[P, T]:
    return func

@decorator
def identity[T](value: T) -> T:
    return value

@decorator
def pair[T, U](first: T, second: U) -> tuple[T, U]:
    return (first, second)

# revealed: ty_extensions.GenericContext[T@identity]
reveal_type(generic_context(identity))
# revealed: ty_extensions.GenericContext[T@pair, U@pair]
reveal_type(generic_context(pair))

reveal_type(identity(1))  # revealed: Literal[1]
reveal_type(identity("hello"))  # revealed: Literal["hello"]

reveal_type(pair(1, "a"))  # revealed: tuple[Literal[1], Literal["a"]]
reveal_type(pair("x", 2.5))  # revealed: tuple[Literal["x"], float]

Chained decorators with generic functions

py
from typing import Callable

def decorator1[**P, R](func: Callable[P, R]) -> Callable[P, R]:
    return func

def decorator2[**P, R](func: Callable[P, R]) -> Callable[P, R]:
    return func

@decorator1
@decorator2
def chained_generic[T](value: T) -> T:
    return value

reveal_type(chained_generic(42))  # revealed: Literal[42]
reveal_type(chained_generic("test"))  # revealed: Literal["test"]

Generic method decoration

py
from typing import Callable
from ty_extensions import generic_context

def method_decorator[**P, R](func: Callable[P, R]) -> Callable[P, R]:
    return func

class Container:
    @method_decorator
    def generic_method[T](self, value: T) -> T:
        return value

c = Container()

# revealed: ty_extensions.GenericContext[T@generic_method]
reveal_type(generic_context(c.generic_method))

reveal_type(c.generic_method)  # revealed: [T](value: T) -> T
reveal_type(c.generic_method(100))  # revealed: Literal[100]
reveal_type(c.generic_method([1, 2, 3]))  # revealed: list[int]

Callable protocols with ParamSpec and class constructors

When a class is passed to a function expecting a callable protocol with ParamSpec, the ParamSpec should be inferred from the class's constructor signature. This inferred signature must then be used to validate any additional arguments that use the ParamSpec components.

py
from typing import Protocol

class ParentClass: ...

class MyProto[**P](Protocol):
    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> ParentClass: ...

class MyType(ParentClass):
    def __init__(self, x: int):
        pass

def create[**P](p: MyProto[P], *args: P.args, **kwargs: P.kwargs) -> ParentClass:
    return p(*args, **kwargs)

# When MyType is passed, P should be inferred as [x: int] from MyType's __init__.
# Since create() requires *args: P.args and **kwargs: P.kwargs, and P is [x: int],
# we must provide the `x` argument.

# error: [missing-argument] "No argument provided for required parameter `x`"
create(MyType)

# These should work since we're providing the required argument
create(MyType, 1)
create(MyType, x=1)

# error: [invalid-argument-type]
create(MyType, "wrong type")

A class with no required constructor parameters should work without additional arguments:

py
class NoArgs(ParentClass):
    pass

create(NoArgs)  # OK - P is inferred as []

Multiple parameters:

py
class MultiParam(ParentClass):
    def __init__(self, x: int, y: str):
        pass

# error: [missing-argument]
create(MultiParam)

# error: [missing-argument]
create(MultiParam, 1)

create(MultiParam, 1, "hello")
create(MultiParam, x=1, y="hello")
create(MultiParam, 1, y="hello")

# error: [too-many-positional-arguments]
create(MultiParam, 1, "hello", "extra")

# error: [invalid-argument-type]
create(MultiParam, "wrong", "hello")

Optional parameters (default values):

py
class WithDefaults(ParentClass):
    def __init__(self, x: int, y: str = "default"):
        pass

# error: [missing-argument]
create(WithDefaults)

# OK - y has a default
create(WithDefaults, 1)
create(WithDefaults, 1, "custom")
create(WithDefaults, x=1)
create(WithDefaults, x=1, y="custom")

Keyword-only parameters:

py
class KeywordOnly(ParentClass):
    def __init__(self, *, x: int):
        pass

# error: [missing-argument]
create(KeywordOnly)

# Passing positional where keyword-only is expected
# error: [missing-argument]
# error: [too-many-positional-arguments]
create(KeywordOnly, 1)

create(KeywordOnly, x=1)

Positional-only parameters:

py
class PositionalOnly(ParentClass):
    def __init__(self, x: int, /):
        pass

# error: [missing-argument]
create(PositionalOnly)

create(PositionalOnly, 1)

# error: [positional-only-parameter-as-kwarg]
create(PositionalOnly, x=1)

The protocol requires the return type to be ParentClass, so passing a class that doesn't inherit from ParentClass should produce an error:

py
class Unrelated:
    def __init__(self, x: int):
        pass

# error: [invalid-argument-type]
create(Unrelated, 1)

When the protocol has parameters before the ParamSpec (i.e., Concatenate-style signatures), the callable should still match correctly and P should be inferred as empty:

py
def my_factory(arg: str) -> int:
    return 0

class Factory[**P](Protocol):
    def __call__(self, arg: str, *args: P.args, **kwargs: P.kwargs) -> int: ...

def call_factory[**P](ctr: Factory[P], *args: P.args, **kwargs: P.kwargs) -> int:
    return ctr("", *args, **kwargs)

call_factory(my_factory)