PEP: 677 Title: Callable Type Syntax Author: Steven Troxler
<steven.troxler@gmail.com>, Pradeep Kumar Srinivasan
<gohanpra@gmail.com> Sponsor: Guido van Rossum <guido at python.org>
Discussions-To: python-dev@python.org Status: Rejected Type: Standards
Track Topic: Typing Content-Type: text/x-rst Created: 13-Dec-2021
Python-Version: 3.11 Post-History: 16-Dec-2021 Resolution:
https://mail.python.org/archives/list/python-dev@python.org/message/NHCLHCU2XCWTBGF732WESMN42YYVKOXB/

Abstract

This PEP introduces a concise and friendly syntax for callable types,
supporting the same functionality as typing.Callable but with an arrow
syntax inspired by the syntax for typed function signatures. This allows
types like Callable[[int, str], bool] to be written as
(int, str) -> bool.

The proposed syntax supports all the functionality provided by
typing.Callable and typing.Concatenate, and is intended to work as a
drop-in replacement.

Motivation

One way to make code safer and easier to analyze is by making sure that
functions and classes are well-typed. In Python we have type
annotations, the framework for which is defined in PEP 484, to provide
type hints that can find bugs as well as helping with editor tooling
like tab completion, static analysis tooling, and code review.

Consider the following untyped code:

    def flat_map(func, l):
        out = []
        for element in l:
            out.extend(func(element))
        return out


    def wrap(x: int) -> list[int]:
        return [x]

    def add(x: int, y: int) -> int:
        return x + y

    flat_map(wrap, [1, 2, 3])  # no runtime error, output is [1, 2, 3]
    flat_map(add, [1, 2, 3])   # runtime error: `add` expects 2 arguments, got 1

We can add types to this example to detect the runtime error:

    from typing import Callable

    def flat_map(
        func: Callable[[int], list[int]],
        l: list[int]
    ) -> list[int]:
        ....

    ...


    flat_map(wrap, [1, 2, 3])  # type checks okay, output is [1, 2, 3]
    flat_map(add, [1, 2, 3])   # type check error

There are a few usability challenges with Callable we can see here:

-   It is verbose, particularly for more complex function signatures.
-   It relies on two levels of nested brackets, unlike any other generic
    type. This can be especially hard to read when some of the type
    parameters are themselves generic types.
-   The bracket structure is not visually similar to how function
    signatures are written.
-   It requires an explicit import, unlike many of the other most common
    types like list and dict.

Possibly as a result, programmers often fail to write complete Callable
types. Such untyped or partially-typed callable types do not check the
parameter types or return types of the given callable and thus negate
the benefits of static typing. For example, they might write this:

    from typing import Callable

    def flat_map(
        func: Callable[..., Any],
        l: list[int]
    ) -> list[int]:
        ....

    ...


    flat_map(add, [1, 2, 3])  # oops, no type check error!

There's some partial type information here - we at least know that func
needs to be callable. But we've dropped too much type information for
type checkers to find the bug.

With our proposal, the example looks like this:

    def flat_map(
        func: (int) -> list[int],
        l: list[int]
    ) -> list[int]:
        out = []
        for element in l:
            out.extend(f(element))
        return out

    ...

The type (int) -> list[int] is more concise, uses an arrow similar to
the one indicating a return type in a function header, avoids nested
brackets, and does not require an import.

Rationale

The Callable type is widely used. For example, as of October 2021 it was
the fifth most common complex type in typeshed, after Optional, Tuple,
Union, and List.

The others have had their syntax improved and the need for imports
eliminated by either PEP 604 or PEP 585:

-   typing.Optional[int] is written int | None
-   typing.Union[int, str] is written int | str
-   typing.List[int] is written list[int]
-   typing.Tuple[int, str] is written tuple[int, str]

The typing.Callable type is used almost as often as these other types,
is more complicated to read and write, and still requires an import and
bracket-based syntax.

In this proposal, we chose to support all the existing semantics of
typing.Callable, without adding support for new features. We made this
decision after examining how frequently each feature might be used in
existing typed and untyped open-source code. We determined that the vast
majority of use cases are covered.

We considered adding support for named, optional, and variadic
arguments. However, we decided against including these features, as our
analysis showed they are infrequently used. When they are really needed,
it is possible to type these using callback protocols.

An Arrow Syntax for Callable Types

We are proposing a succinct, easy-to-use syntax for typing.Callable that
looks similar to function headers in Python. Our proposal closely
follows syntax used by several popular languages such as Typescript,
Kotlin, and Scala.

Our goals are that:

-   Callable types using this syntax will be easier to learn and use,
    particularly for developers with experience in other languages.
-   Library authors will be more likely to use expressive types for
    callables that enable type checkers to better understand code and
    find bugs, as in the decorator example above.

Consider this simplified real-world example from a web server, written
using the existing typing.Callable:

    from typing import Awaitable, Callable
    from app_logic import Response, UserSetting


    def customize_response(
        response: Response,
        customizer: Callable[[Response, list[UserSetting]], Awaitable[Response]]
    ) -> Response:
       ...

With our proposal, this code can be abbreviated to:

    from app_logic import Response, UserSetting

    def customize_response(
        response: Response,
        customizer: async (Response, list[UserSetting]) -> Response,
    ) -> Response:
        ...

This is shorter and requires fewer imports. It also has far less nesting
of square brackets - only one level, as opposed to three in the original
code.

Compact Syntax for ParamSpec

A particularly common case where library authors leave off type
information for callables is when defining decorators. Consider the
following:

    from typing import Any, Callable

    def with_retries(
        f: Callable[..., Any]
    ) -> Callable[..., Any]:
        def wrapper(retry_once, *args, **kwargs):
            if retry_once:
                try: return f(*args, **kwargs)
                except Exception: pass
            return f(*args, **kwargs)
        return wrapper

    @with_retries
    def f(x: int) -> int:
        return x


    f(y=10)  # oops - no type error!

In the code above, it is clear that the decorator should produce a
function whose signature is like that of the argument f other than an
additional retry_once argument. But the use of ... prevents a type
checker from seeing this and alerting a user that f(y=10) is invalid.

With PEP 612 it is possible to type decorators like this correctly as
follows:

    from typing import Any, Callable, Concatenate, ParamSpec, TypeVar

    R = TypeVar("R")
    P = ParamSpec("P")

    def with_retries(
        f: Callable[P, R]
    ) -> Callable[Concatenate[bool, P] R]:
        def wrapper(retry_once: bool, *args: P.args, **kwargs: P.kwargs) -> R:
            ...
        return wrapper

    ...

With our proposed syntax, the properly-typed decorator example becomes
concise and the type representations are visually descriptive:

    from typing import Any, ParamSpec, TypeVar

    R = TypeVar("R")
    P = ParamSpec("P")

    def with_retries(
        f: (**P) -> R
    ) -> (bool, **P) -> R:
        ...

Comparing to Other Languages

Many popular programming languages use an arrow syntax similar to the
one we are proposing here.

TypeScript

In TypeScript, function types are expressed in a syntax almost the same
as the one we are proposing, but the arrow token is => and arguments
have names:

    (x: int, y: str) => bool

The names of the arguments are not actually relevant to the type. So,
for example, this is the same callable type:

    (a: int, b: str) => bool

Kotlin

Function types in Kotlin permit an identical syntax to the one we are
proposing, for example:

    (Int, String) -> Bool

It also optionally allows adding names to the arguments, for example:

    (x: Int, y: String) -> Bool

As in TypeScript, the argument names (if provided) are just there for
documentation and are not part of the type itself.

Scala

Scala uses the => arrow for function types. Other than that, their
syntax is the same as the one we are proposing, for example:

    (Int, String) => Bool

Scala, like Python, has the ability to provide function arguments by
name. Function types can optionally include names, for example:

    (x: Int, y: String) => Bool

Unlike in TypeScript and Kotlin, these names are part of the type if
provided - any function implementing the type must use the same names.
This is similar to the extended syntax proposal we describe in our
Rejected Alternatives section.

Function Definitions vs Callable Type Annotations

In all of the languages listed above, type annotations for function
definitions use a : rather than a ->. For example, in TypeScript a
simple add function looks like this:

    function higher_order(fn: (a: string) => string): string {
      return fn("Hello, World");
    }

Scala and Kotlin use essentially the same : syntax for return
annotations. The : makes sense in these languages because they all use :
for type annotations of parameters and variables, and the use for
function return types is similar.

In Python we use : to denote the start of a function body and -> for
return annotations. As a result, even though our proposal is
superficially the same as these other languages the context is
different. There is potential for more confusion in Python when reading
function definitions that include callable types.

This is a key concern for which we are seeking feedback with our draft
PEP; one idea we have floated is to use => instead to make it easier to
differentiate.

The ML Language Family

Languages in the ML family, including F#, OCaml, and Haskell, all use ->
to represent function types. All of them use a parentheses-free syntax
with multiple arrows, for example in Haskell:

    Integer -> String -> Bool

The use of multiple arrows, which differs from our proposal, makes sense
for languages in this family because they use automatic currying of
function arguments, which means that a multi-argument function behaves
like a single-argument function returning a function.

Specification

Typing Behavior

Type checkers should treat the new syntax with exactly the same
semantics as typing.Callable.

As such, a type checker should treat the following pairs exactly the
same:

    from typing import Awaitable, Callable, Concatenate, ParamSpec, TypeVarTuple

    P = ParamSpec("P")
    Ts = TypeVarTuple('Ts')

    f0: () -> bool
    f0: Callable[[], bool]

    f1: (int, str) -> bool
    f1: Callable[[int, str], bool]

    f2: (...) -> bool
    f2: Callable[..., bool]

    f3: async (str) -> str
    f3: Callable[[str], Awaitable[str]]

    f4: (**P) -> bool
    f4: Callable[P, bool]

    f5: (int, **P) -> bool
    f5: Callable[Concatenate[int, P], bool]

    f6: (*Ts) -> bool
    f6: Callable[[*Ts], bool]

    f7: (int, *Ts, str) -> bool
    f7: Callable[[int, *Ts, str], bool]

Grammar and AST

The proposed new syntax can be described by these AST changes to
Parser/Python.asdl:

    expr = <prexisting_expr_kinds>
         | AsyncCallableType(callable_type_arguments args, expr returns)
         | CallableType(callable_type_arguments args, expr returns)

    callable_type_arguments = AnyArguments
                            | ArgumentsList(expr* posonlyargs)
                            | Concatenation(expr* posonlyargs, expr param_spec)

Here are our proposed changes to the `Python Grammar
<https://docs.python.org/3/reference/grammar.htm>`:

    expression:
        | disjunction disjunction 'else' expression
        | callable_type_expression
        | disjunction
        | lambdef

    callable_type_expression:
        | callable_type_arguments '->' expression
        | ASYNC callable_type_arguments '->' expression

    callable_type_arguments:
        | '(' '...' [','] ')'
        | '(' callable_type_positional_argument*  ')'
        | '(' callable_type_positional_argument* callable_type_param_spec ')'

    callable_type_positional_argument:
        | !'...' expression ','
        | !'...' expression &')'

    callable_type_param_spec:
        | '**' expression ','
        | '**' expression &')'

If PEP 646 is accepted, we intend to include support for unpacked types
in two ways. To support the "star-for-unpack" syntax proposed in PEP
646, we will modify the grammar for callable_type_positional_argument as
follows:

    callable_type_positional_argument:
        | !'...' expression ','
        | !'...' expression &')'
        | '*' expression ','
        | '*' expression &')'

With this change, a type of the form (int, *Ts) -> bool should evaluate
the AST form:

    CallableType(
        ArgumentsList(Name("int"), Starred(Name("Ts")),
        Name("bool")
    )

and be treated by type checkers as equivalent to or
Callable[[int, *Ts], bool] or Callable[[int, Unpack[Ts]], bool].

Implications of the Grammar

Precedence of ->

-> binds less tightly than other operators, both inside types and in
function signatures, so the following two callable types are equivalent:

    (int) -> str | bool
    (int) -> (str | bool)

-> associates to the right, both inside types and in function
signatures. So the following pairs are equivalent:

    (int) -> (str) -> bool
    (int) -> ((str) -> bool)

    def f() -> (int, str) -> bool: pass
    def f() -> ((int, str) -> bool): pass

    def f() -> (int) -> (str) -> bool: pass
    def f() -> ((int) -> ((str) -> bool)): pass

Because operators bind more tightly than ->, parentheses are required
whenever an arrow type is intended to be inside an argument to an
operator like |:

    (int) -> () -> int | () -> bool      # syntax error!
    (int) -> (() -> int) | (() -> bool)  # okay

We discussed each of these behaviors and believe they are desirable:

-   Union types (represented by A | B according to PEP 604) are valid in
    function signature returns, so we need to allow operators in the
    return position for consistency.
-   Given that operators bind more tightly than -> it is correct that a
    type like bool | () -> bool must be a syntax error. We should be
    sure the error message is clear because this may be a common
    mistake.
-   Associating -> to the right, rather than requiring explicit
    parentheses, is consistent with other languages like TypeScript and
    respects the principle that valid expressions should normally be
    substitutable when possible.

async Keyword

All of the binding rules still work for async callable types:

    (int) -> async (float) -> str | bool
    (int) -> (async (float) -> (str | bool))

    def f() -> async (int, str) -> bool: pass
    def f() -> (async (int, str) -> bool): pass

    def f() -> async (int) -> async (str) -> bool: pass
    def f() -> (async (int) -> (async (str) -> bool)): pass

Trailing Commas

-   Following the precedent of function signatures, putting a comma in
    an empty arguments list is illegal: (,) -> bool is a syntax error.

-   Again following precedent, trailing commas are otherwise always
    permitted:

        ((int,) -> bool == (int) -> bool
        ((int, **P,) -> bool == (int, **P) -> bool
        ((...,) -> bool) == ((...) -> bool)

Allowing trailing commas also gives autoformatters more flexibility when
splitting callable types across lines, which is always legal following
standard python whitespace rules.

Disallowing ... as an Argument Type

Under normal circumstances, any valid expression is permitted where we
want a type annotation and ... is a valid expression. This is never
semantically valid and all type checkers would reject it, but the
grammar would allow it if we did not explicitly prevent this.

Since ... is meaningless as a type and there are usability concerns, our
grammar rules it out and the following is a syntax error:

    (int, ...) -> bool

We decided that there were compelling reasons to do this:

-   The semantics of (...) -> bool are different from (T) -> bool for
    any valid type T: (...) is a special form indicating AnyArguments
    whereas T is a type parameter in the arguments list.
-   ... is used as a placeholder default value to indicate an optional
    argument in stubs and callback protocols. Allowing it in the
    position of a type could easily lead to confusion and possibly bugs
    due to typos.
-   In the tuple generic type, we special-case ... to mean "more of the
    same", e.g. a tuple[int, ...] means a tuple with one or more
    integers. We do not use ... in a a similar way in callable types, so
    to prevent misunderstandings it makes sense to prevent this.

Incompatibility with other possible uses of * and **

The use of **P for supporting PEP 612 ParamSpec rules out any future
proposal using a bare **<some_type> to type kwargs. This seems
acceptable because:

-   If we ever do want such a syntax, it would be clearer to require an
    argument name anyway. This would also make the type look more
    similar to a function signature. In other words, if we ever support
    typing kwargs in callable types, we would prefer
    (int, **kwargs: str) rather than (int, **str).
-   PEP 646 unpacking syntax would rule out using *<some_type> for args.
    The kwargs case is similar enough that this rules out a bare
    **<some_type> anyway.

Compatibility with Arrow-Based Lambda Syntax

To the best of our knowledge there is no active discussion of
arrow-style lambda syntax that we are aware of, but it is nonetheless
worth considering what possibilities would be ruled out by adopting this
proposal.

It would be incompatible with this proposal to adopt the same a
parenthesized ->-based arrow syntax for lambdas, e.g. (x, y) -> x + y
for lambda x, y: x + y.

Our view is that if we want arrow syntax for lambdas in the future, it
would be a better choice to use =>, e.g. (x, y) => x + y. Many languages
use the same arrow token for both lambdas and callable types, but Python
is unique in that types are expressions and have to evaluate to runtime
values. Our view is that this merits using separate tokens, and given
the existing use of -> for return types in function signatures it would
be more coherent to use -> for callable types and => for lambdas.

Runtime Behavior

The new AST nodes need to evaluate to runtime types, and we have two
goals for the behavior of these runtime types:

-   They should expose a structured API that is descriptive and powerful
    enough to be compatible with extending the type to include new
    features like named and variadic arguments.
-   They should also expose an API that is backward-compatible with
    typing.Callable.

Evaluation and Structured API

We intend to create new builtin types to which the new AST nodes will
evaluate, exposing them in the types module.

Our plan is to expose a structured API as if they were defined as
follows:

    class CallableType:
        is_async: bool
        arguments: Ellipsis | tuple[CallableTypeArgument]
        return_type: object

    class CallableTypeArgument:
        kind: CallableTypeArgumentKind
        annotation: object

    @enum.global_enum
    class CallableTypeArgumentKind(enum.IntEnum):
        POSITIONAL_ONLY: int = ...
        PARAM_SPEC: int = ...

The evaluation rules are expressed in terms of the following pseudocode:

    def evaluate_callable_type(
        callable_type: ast.CallableType | ast.AsyncCallableType:
    ) -> CallableType:
        return CallableType(
           is_async=isinstance(callable_type, ast.AsyncCallableType),
           arguments=_evaluate_arguments(callable_type.arguments),
           return_type=evaluate_expression(callable_type.returns),
        )

    def _evaluate_arguments(arguments):
        match arguments:
            case ast.AnyArguments():
                return Ellipsis
            case ast.ArgumentsList(posonlyargs):
                return tuple(
                    _evaluate_arg(arg) for arg in args
                )
            case ast.ArgumentsListConcatenation(posonlyargs, param_spec):
                return tuple(
                    *(evaluate_arg(arg) for arg in args),
                    _evaluate_arg(arg=param_spec, kind=PARAM_SPEC)
                )
            if isinstance(arguments, Any
        return Ellipsis

    def _evaluate_arg(arg, kind=POSITIONAL_ONLY):
        return CallableTypeArgument(
            kind=POSITIONAL_ONLY,
            annotation=evaluate_expression(value)
        )

Backward-Compatible API

To get backward compatibility with the existing types.Callable API,
which relies on fields __args__ and __parameters__, we can define them
as if they were written in terms of the following:

    import itertools
    import typing

    def get_args(t: CallableType) -> tuple[object]:
        return_type_arg = (
            typing.Awaitable[t.return_type]
            if t.is_async
            else t.return_type
        )
        arguments = t.arguments
        if isinstance(arguments, Ellipsis):
            argument_args = (Ellipsis,)
        else:
            argument_args = (arg.annotation for arg in arguments)
        return (
            *arguments_args,
            return_type_arg
        )

    def get_parameters(t: CallableType) -> tuple[object]:
        out = []
        for arg in get_args(t):
            if isinstance(arg, typing.ParamSpec):
                out.append(t)
            else:
                out.extend(arg.__parameters__)
        return tuple(out)

Additional Behaviors of types.CallableType

As with the A | B syntax for unions introduced in PEP 604:

-   The __eq__ method should treat equivalent typing.Callable values as
    equal to values constructed using the builtin syntax, and otherwise
    should behave like the __eq__ of typing.Callable.
-   The __repr__ method should produce an arrow syntax representation
    that, when evaluated, gives us back an equal types.CallableType
    instance.

Rejected Alternatives

Many of the alternatives we considered would have been more expressive
than typing.Callable, for example adding support for describing
signatures that include named, optional, and variadic arguments.

To determine which features we most needed to support with a callable
type syntax, we did an extensive analysis of existing projects:

-   stats on the use of the Callable type;
-   stats on how untyped and partially-typed callbacks are actually
    used.

We decided on a simple proposal with improved syntax for the existing
Callable type because the vast majority of callbacks can be correctly
described by the existing typing.Callable semantics:

-   Positional parameters: By far the most important case to handle well
    is simple callable types with positional parameters, such as
    (int, str) -> bool
-   ParamSpec and Concatenate: The next most important feature is good
    support for PEP 612 ParamSpec and Concatenate types like
    (**P) -> bool and (int, **P) -> bool. These are common primarily
    because of the heavy use of decorator patterns in python code.
-   TypeVarTuples: The next most important feature, assuming PEP 646 is
    accepted, is for unpacked types which are common because of cases
    where a wrapper passes along *args to some other function.

Features that other, more complicated proposals would support account
for fewer than 2% of the use cases we found. These are already
expressible using callback protocols, and since they are uncommon we
decided that it made more sense to move forward with a simpler syntax.

Extended Syntax Supporting Named and Optional Arguments

Another alternative was for a compatible but more complex syntax that
could express everything in this PEP but also named, optional, and
variadic arguments. In this “extended” syntax proposal the following
types would have been equivalent:

    class Function(typing.Protocol):
        def f(self, x: int, /, y: float, *, z: bool = ..., **kwargs: str) -> bool:
            ...

    Function = (int, y: float, *, z: bool = ..., **kwargs: str) -> bool

Advantages of this syntax include: - Most of the advantages of the
proposal in this PEP (conciseness, PEP 612 support, etc) -Furthermore,
the ability to handle named, optional, and variadic arguments

We decided against proposing it for the following reasons:

-   The implementation would have been more difficult, and usage stats
    demonstrate that fewer than 3% of use cases would benefit from any
    of the added features.

-   The group that debated these proposals was split down the middle
    about whether these changes are desirable:

    -   On the one hand, they make callable types more expressive. On
        the other hand, they could easily confuse users who have not
        read the full specification of callable type syntax.
    -   We believe the simpler syntax proposed in this PEP, which
        introduces no new semantics and closely mimics syntax in other
        popular languages like Kotlin, Scala, and TypesScript, is much
        less likely to confuse users.

-   We intend to implement the current proposal in a way that is
    forward-compatible with the more complicated extended syntax. If the
    community decides after more experience and discussion that we want
    the additional features, it should be straightforward to propose
    them in the future.

-   Even a full extended syntax cannot replace the use of callback
    protocols for overloads. For example, no closed form of callable
    type could express a function that maps bools to bools and ints to
    floats, like this callback protocol.:

        from typing import overload, Protocol

        class OverloadedCallback(Protocol)

          @overload
          def __call__(self, x: int) -> float: ...

          @overload
          def __call__(self, x: bool) -> bool: ...

          def __call__(self, x: int | bool) -> float | bool: ...


        f: OverloadedCallback = ...
        f(True)  # bool
        f(3)     # float

We confirmed that the current proposal is forward-compatible with
extended syntax by implementing a grammar and AST for this extended
syntax on top of our reference implementation of this PEP's grammar.

Syntax Closer to Function Signatures

One alternative we had floated was a syntax much more similar to
function signatures.

In this proposal, the following types would have been equivalent:

    class Function(typing.Protocol):
        def f(self, x: int, /, y: float, *, z: bool = ..., **kwargs: str) -> bool:
            ...

    Function = (x: int, /, y: float, *, z: bool = ..., **kwargs: str) -> bool

The benefits of this proposal would have included:

-   Perfect syntactic consistency between signatures and callable types.
-   Support for more features of function signatures (named, optional,
    variadic args) that this PEP does not support.

Key downsides that led us to reject the idea include the following:

-   A large majority of use cases only use positional-only arguments.
    This syntax would be more verbose for that use case, both because of
    requiring argument names and an explicit /, for example
    (int, /) -> bool where our proposal allows (int) -> bool
-   The requirement for explicit / for positional-only arguments has a
    high risk of causing frequent bugs - which often would not be
    detected by unit tests - where library authors would accidentally
    use types with named arguments.
-   Our analysis suggests that support for ParamSpec is key, but the
    scoping rules laid out in PEP 612 would have made this difficult.

Other Proposals Considered

Functions-as-Types

An idea we looked at very early on was to allow using functions as
types. The idea is allowing a function to stand in for its own call
signature, with roughly the same semantics as the __call__ method of
callback protocols:

    def CallableType(
        positional_only: int,
        /,
        named: str,
        *args: float,
        keyword_only: int = ...,
        **kwargs: str
    ) -> bool: ...

    f: CallableType = ...
    f(5, 6.6, 6.7, named=6, x="hello", y="world")  # typechecks as bool

This may be a good idea, but we do not consider it a viable replacement
for callable types:

-   It would be difficult to handle ParamSpec, which we consider a
    critical feature to support.
-   When using functions as types, the callable types are not
    first-class values. Instead, they require a separate, out-of-line
    function definition to define a type alias
-   It would not support more features than callback protocols, and
    seems more like a shorter way to write them than a replacement for
    Callable.

Hybrid keyword-arrow Syntax

In the Rust language, a keyword fn is used to indicate functions in much
the same way as Python's def, and callable types are indicated using a
hybrid arrow syntax Fn(i64, String) -> bool.

We could use the def keyword in callable types for Python, for example
our two-parameter boolean function could be written as
def(int, str) -> bool. But we think this might confuse readers into
thinking def(A, B) -> C is a lambda, particularly because Javascript's
function keyword is used in both named and anonymous functions.

Parenthesis-Free Syntax

We considered a parentheses-free syntax that would have been even more
concise:

    int, str -> bool

We decided against it because this is not visually as similar to
existing function header syntax. Moreover, it is visually similar to
lambdas, which bind names with no parentheses: lambda x, y: x == y.

Requiring Outer Parentheses

A concern with the current proposal is readability, particularly when
callable types are used in return type position which leads to multiple
top-level -> tokens, for example:

    def make_adder() -> (int) -> int:
        return lambda x: x + 1

We considered a few ideas to prevent this by changing rules about
parentheses. One was to move the parentheses to the outside, so that a
two-argument boolean function is written (int, str -> bool). With this
change, the example above becomes:

    def make_adder() -> (int -> int):
        return lambda x: x + 1

This makes the nesting of many examples that are difficult to follow
clear, but we rejected it because

-   Currently in Python commas bind very loosely, which means it might
    be common to misread (int, str -> bool) as a tuple whose first
    element is an int, rather than a two-parameter callable type.
-   It is not very similar to function header syntax, and one of our
    goals was familiar syntax inspired by function headers.
-   This syntax may be more readable for deaply nested callables like
    the one above, but deep nesting is not very common. Encouraging
    extra parentheses around callable types in return position via a
    style guide would have most of the readability benefit without the
    downsides.

We also considered requiring parentheses on both the parameter list and
the outside, e.g. ((int, str) -> bool). With this change, the example
above becomes:

    def make_adder() -> ((int) -> int):
        return lambda x: x + 1

We rejected this change because:

-   The outer parentheses only help readability in some cases, mostly
    when a callable type is used in return position. In many other cases
    they hurt readability rather than helping.
-   We agree that it might make sense to encourage outer parentheses in
    several cases, particularly callable types in function return
    annotations. But
    -   We believe it is more appropriate to encourage this in style
        guides, linters, and autoformatters than to bake it into the
        parser and throw syntax errors.

    -   Moreover, if a type is complicated enough that readability is a
        concern we can always use type aliases, for example:

            IntToIntFunction: (int) -> int

            def make_adder() -> IntToIntFunction:
                return lambda x: x + 1

Making -> bind tighter than |

In order to allow both -> and | tokens in type expressions we had to
choose precedence. In the current proposal, this is a function returning
an optional boolean:

    (int, str) -> bool | None  # equivalent to (int, str) -> (bool | None)

We considered having -> bind tighter so that instead the expression
would parse as ((int, str) -> bool) | None. There are two advantages to
this:

-   It means we no would longer have to treat None | (int, str) -> bool
    as a syntax error.
-   Looking at typeshed today, optional callable arguments are very
    common because using None as a default value is a standard Python
    idiom. Having -> bind tighter would make these easier to write.

We decided against this for a few reasons:

-   The function header def f() -> int | None: ... is legal and
    indicates a function returning an optional int. To be consistent
    with function headers, callable types should do the same.
-   TypeScript is the other popular language we know of that uses both
    -> and | tokens in type expressions, and they have | bind tighter.
    While we do not have to follow their lead, we prefer to do so.
-   We do acknowledge that optional callable types are common and having
    | bind tighter forces extra parentheses, which makes these types
    harder to write. But code is read more often than written, and we
    believe that requiring the outer parentheses for an optional
    callable type like ((int, str) -> bool) | None is preferable for
    readability.

Introducing type-strings

Another idea was adding a new “special string” syntax and putting the
type inside of it, for example t”(int, str) -> bool”. We rejected this
because it is not as readable, and seems out of step with guidance from
the Steering Council on ensuring that type expressions do not diverge
from the rest of Python's syntax.

Improving Usability of the Indexed Callable Type

If we do not want to add new syntax for callable types, we could look at
how to make the existing type easier to read. One proposal would be to
make the builtin callable function indexable so that it could be used as
a type:

    callable[[int, str], bool]

This change would be analogous to PEP 585 that made built in collections
like list and dict usable as types, and would make imports more
convenient, but it wouldn't help readability of the types themselves
much.

In order to reduce the number of brackets needed in complex callable
types, it would be possible to allow tuples for the argument list:

    callable[(int, str), bool]

This actually is a significant readability improvement for
multi-argument functions, but the problem is that it makes callables
with one arguments, which are the most common arity, hard to write:
because (x) evaluates to x, they would have to be written like
callable[(int,), bool]. We find this awkward.

Moreover, none of these ideas help as much with reducing verbosity as
the current proposal, nor do they introduce as strong a visual cue as
the -> between the parameter types and the return type.

Alternative Runtime Behaviors

The hard requirements on our runtime API are that:

-   It must preserve backward compatibility with typing.Callable via
    __args__ and __params__.
-   It must provide a structured API, which should be extensible if in
    the future we try to support named and variadic arguments.

Alternative APIs

We considered having the runtime data types.CallableType use a more
structured API where there would be separate fields for posonlyargs and
param_spec. The current proposal was was inspired by the
inspect.Signature type.

We use "argument" in our field and type names, unlike "parameter" as in
inspect.Signature, in order to avoid confusion with the
callable_type.__parameters__ field from the legacy API that refers to
type parameters rather than callable parameters.

Using the plain return type in __args__ for async types

It is debatable whether we are required to preserve backward
compatibility of __args__ for async callable types like
async (int) -> str. The reason is that one could argue they are not
expressible directly using typing.Callable, and therefore it would be
fine to set __args__ as (int, int) rather than
(int, typing.Awaitable[int]).

But we believe this would be problematic. By preserving the appearance
of a backward-compatible API while actually breaking its semantics on
async types, we would cause runtime type libraries that attempt to
interpret Callable using __args__ to fail silently.

It is for this reason that we automatically wrap the return type in
Awaitable.

Backward Compatibility

This PEP proposes a major syntax improvement over typing.Callable, but
the static semantics are the same.

As such, the only thing we need for backward compatibility is to ensure
that types specified via the new syntax behave the same as equivalent
typing.Callable and typing.Concatenate values they intend to replace.

There is no particular interaction between this proposal and
from __future__ import annotations - just like any other type annotation
it will be unparsed to a string at module import, and
typing.get_type_hints should correctly evaluate the resulting strings in
cases where that is possible.

This is discussed in more detail in the Runtime Behavior section.

Reference Implementation

We have a working implementation of the AST and Grammar with tests
verifying that the grammar proposed here has the desired behaviors.

The runtime behavior is not yet implemented. As discussed in the Runtime
Behavior portion of the spec we have a detailed plan for both a
backward-compatible API and a more structured API in a separate doc
where we are also open to discussion and alternative ideas.

Open Issues

Details of the Runtime API

We have attempted to provide a complete behavior specification in the
Runtime Behavior section of this PEP.

But there are probably more details that we will not realize we need to
define until we build a full reference implementation.

Optimizing SyntaxError messages

The current reference implementation has a fully-functional parser and
all edge cases presented here have been tested.

But there are some known cases where the errors are not as informative
as we would like. For example, because (int, ...) -> bool is illegal but
(int, ...) is a valid tuple, we currently produce a syntax error
flagging the -> as the problem even though the real cause of the error
is using ... as an argument type.

This is not part of the specification per se but is an important detail
to address in our implementation. The solution will likely involve
adding invalid_.* rules to python.gram and customizing error messages.

Resources

Background and History

PEP 484 specifies
<484#suggested-syntax-for-python-2-7-and-straddling-code> a very similar
syntax for function type hint comments for use in code that needs to
work on Python 2.7. For example:

    def f(x, y):
        # type: (int, str) -> bool
        ...

At that time we used indexing operations to specify generic types like
typing.Callable because we decided not to add syntax for types. However,
we have since begun to do so, e.g. with PEP 604.

Maggie proposed better callable type syntax as part of a larger
presentation on typing simplifications at the PyCon Typing Summit 2021.

Steven brought up this proposal on typing-sig. We had several meetings
to discuss alternatives, and this presentation led us to the current
proposal.

Pradeep brought this proposal to python-dev for feedback.

Acknowledgments

Thanks to the following people for their feedback on the PEP and help
planning the reference implementation:

Alex Waygood, Eric Traut, Guido van Rossum, James Hilton-Balfe, Jelle
Zijlstra, Maggie Moss, Tuomas Suutari, Shannon Zhu.

Copyright

This document is placed in the public domain or under the
CC0-1.0-Universal license, whichever is more permissive.