2025, Nov 17 03:00

How to Type Callables with Positional-only and Keyword-only Arguments in Python: Protocol vs Callable

Learn how to type Python callables with positional-only and keyword-only parameters. See why Callable falls short and when a Protocol with __call__ is best.

Typing callables with positional-only and keyword-only parameters in Python can be surprisingly tricky. A common expectation is that Callable should be enough to fully describe a function signature, but that breaks down when you need to enforce argument kinds. Here is what really happens and how to type such functions correctly.

The problem

Suppose you have a callable that mixes positional-only and keyword-only parameters, and you want to annotate a variable to accept it. The first instinct is to reach for Callable, but that will not work for this shape of API.

from typing import Callable

def join_vals(num: int, /, *, suffix: str) -> str:
    return f"{num}{suffix}"

fn_ref: Callable[[int, str], str] = join_vals
# Type checker error:
# `(num: int, /, *, suffix: str) -> str` is not assignable to `(int, str) -> str`

Why this fails

Callable only models positional parameters. It cannot express keyword-only arguments, positional-only markers, variadic arguments, or default values. That design choice is explicit in the typing specification and makes Callable unsuitable for enforcing argument kinds beyond the positional case.

Parameters specified using Callable are assumed to be positional-only. The Callable form provides no way to specify keyword-only parameters, variadic parameters, or default argument values. For these use cases, see the section on Callback protocols.

There is also an important consequence for assignability. A function with standard parameters that can be called both positionally and by keyword can be assigned to many callable types, because it is flexible at the call site. In contrast, a callable that is strictly keyword-only or strictly positional-only can only be assigned to a type that matches its calling convention exactly. That is why the example above fails.

The solution

To type-hint callables with positional-only or keyword-only parameters, use a Protocol with a __call__ that captures the exact signature you want. Callable remains useful for purely positional signatures, but as soon as argument kinds matter, Protocol is the right tool.

from typing import Callable, Protocol

class AnyParams(Protocol):
    def __call__(self, v: int) -> None: ...

class OnlyKw(Protocol):
    def __call__(self, *, v: int) -> None: ...

class OnlyPos(Protocol):
    def __call__(self, v: int, /) -> None: ...

FnSig = Callable[[int], None]

def showcase(p_any: AnyParams, p_kw: OnlyKw, p_call: FnSig, p_pos: OnlyPos):
    # A callable with standard parameters can be assigned broadly
    a1: OnlyKw = p_any  # OK
    a2: FnSig = p_any   # OK
    a3: OnlyPos = p_any # OK

    # Keyword-only callables require matching keyword-only types
    b1: AnyParams = p_kw   # error
    b2: FnSig = p_kw       # error
    b3: OnlyPos = p_kw     # error

    # Callable[[...]] aligns with positional-only shapes
    c1: AnyParams = p_call  # error
    c2: OnlyKw = p_call     # error
    c3: OnlyPos = p_call    # OK - equivalent

    d1: AnyParams = p_pos  # error
    d2: OnlyKw = p_pos     # error
    d3: FnSig = p_pos      # OK - equivalent

This pattern lets you precisely declare what a callback must accept, including whether its parameters are keyword-only or positional-only, and ensures type checkers can validate assignments and calls accordingly.

Why this matters

APIs often use keyword-only parameters to make call sites self-documenting and to prevent accidental argument swapping. Positional-only parameters appear in public interfaces where names are not part of the contract. If your types do not enforce these calling conventions, violations slip through until runtime. Protocol-based annotations catch these mismatches during static analysis, which is exactly where you want to find them.

There is no combination of Callable arguments that expresses keyword-only parameters, so relying on Callable alone for such cases will silently weaken your contracts. The dedicated Protocol approach keeps both readability and correctness intact.

Takeaways

If you need to type a callable with keyword-only or positional-only arguments, describe the signature via a Protocol and its __call__. Use Callable when parameters are purely positional and you do not care about argument kinds. This simple distinction avoids confusing errors, preserves the intent of your API, and gives type checkers all the information they need.