2025, Nov 15 05:00

Make a keyword-only parameter feel optional in Python: decorator strategy with overloads, Protocol, and ParamSpec limits for Pylance

Learn how to make a required keyword-only parameter feel optional in Pylance using a Python decorator, overloads, and Protocol, with notes on ParamSpec limits.

Making a required keyword-only parameter feel optional at call time is a common ergonomics request: you want the runtime to fill it in when it’s missing, but you also want your IDE to show it as optional. The tricky part is reconciling this with static typing and signature display in tools like Pylance.

Problem setup

Assume multiple functions accept a keyword argument kw of a specific type T. At runtime, if kw is omitted, you want a decorator to generate it. At the same time, the editor’s tooltip should display kw as optional with a default of None, even if the real function signature requires it.

The original pattern looks like a function of the shape:

def worker(*args, kw: T, **kwargs): ...

But you’d prefer the IDE to show it like this:

def worker(*args, kw: T | None = None, **kwargs): ...

A naive attempt

One way to attempt this is to adjust the introspected signature and wrap the call, synthesizing kw when absent. Conceptually:

import inspect
from typing import Optional, get_type_hints
from functools import wraps
def make_kw_opt(fn):
    siginfo = inspect.signature(fn)
    ann = get_type_hints(fn)
    kw_hint = ann.get("kw")
    maybe_kw_hint = Optional[kw_hint]
    patched_params = []
    for p in siginfo.parameters.values():
        if p.name == "kw":
            patched_params.append(
                p.replace(
                    annotation=maybe_kw_hint,
                    default=None,
                )
            )
        else:
            patched_params.append(p)
    siginfo2 = siginfo.replace(parameters=patched_params)
    @wraps(fn)
    def callable_proxy(*a, **k):
        if k.get("kw") is None:
            k["kw"] = build_kw()
        return fn(*a, **k)
    # there is no supported way here to make Pylance display the modified tooltip
    return callable_proxy

This gets the runtime behavior you want, but it does not lead Pylance to display the modified signature. In other words, you can’t “patch” what the IDE shows by editing the runtime signature in this way.

Why the signature can’t be rewritten as imagined

With Pylance, altering the presented tooltip to show an arbitrary rewritten signature is not supported in this scenario. To forward an unknown signature reliably, you need a ParamSpec so you can capture and forward positional and keyword parameters. However, you cannot insert a new parameter between a ParamSpec’s varargs and kwargs. That is, you can’t define something like *args: P.args, kw: T, **kwargs: P.kwargs and expect typing tools to accept it as a valid transformation.

You can sometimes describe keyword argument shapes using TypedDict for kwargs, but that approach isn’t as flexible as a ParamSpec for this purpose.

The closest practical approach: overload + Protocol

The most realistic way to influence what Pylance displays is to present two overloads: one that mirrors the original required kw, and a second that shows kw as optional with a default of None. A Protocol is used to express the input and output callable shapes for a decorator. The decorator itself can handle runtime duties and return the original function, while static typing sees it as a function with two overloads.

from typing import overload, Protocol, cast
class NeedsKw[U, V, **Q](Protocol):
    @overload
    @staticmethod
    def __call__(*args, kw: U, **kwargs) -> V: ...
    @overload
    @staticmethod
    def __call__(*args: Q.args, **kwargs: Q.kwargs) -> V: ...
    # Not allowed: placing something between Q.args and Q.kwargs
    # def __call__(*args: Q.args, kw: U, **kwargs: Q.kwargs) -> V: ...
class DefaultedKw[U, V, **Q](Protocol):
    @overload
    @staticmethod
    def __call__(*args: Q.args, **kwargs: Q.kwargs) -> V: ...
    @overload
    @staticmethod
    def __call__(*args, kw: U | None = None, **kwargs) -> V:
        """kw is optional and None by default"""
    # Also not allowed here with ParamSpec in between
    # def __call__(*args: Q.args, kw: U | None = None, **kwargs: Q.kwargs) -> V: ...
def make_kw_flexible[U, V, **Q](fn: NeedsKw[U, V, Q]) -> DefaultedKw[U, V, Q]:
    # Perform any runtime wrapping if needed, then return a view typed as DefaultedKw
    return cast(DefaultedKw[U, V, Q], fn)
@make_kw_flexible
def demo(x: int, *, kw: str, extra: int) -> str:
    ...

This pattern makes the IDE show two signatures for the decorated function. One reflects the original requirement of kw with its concrete type. The other displays kw as optional with a default None, matching the desired tooltip. The call forwarding uses ParamSpec for generality without attempting to splice parameters in between varargs and kwargs.

Edge cases and related notes

There is a narrow case where adding a single overload directly above a function can “trick” the display into showing kw as optional, but it will typically produce warnings because at least two overloads are expected. If kw is the first positional parameter, such a workaround is sometimes feasible. When kw is keyword-only, though, the same limitation applies: a ParamSpec cannot be transformed to add parameters between *args and **kwargs.

There is tooling that can alter type behavior based on a decorator at analysis time. For example, a mypy plugin can implement such a transformation; however, availability of corresponding IDE integrations isn’t established here. There isn’t a standard typing feature that enables arbitrary ParamSpec transformations.

Why this matters

Signature fidelity is more than aesthetics. Teams rely on IDE tooltips and type checkers to communicate contracts. If your tooltip says “kw is optional,” but the underlying type system cannot represent that transformation formally, you risk confusing readers and static tools. Staying within what Pylance and the type system can express—overloads and Protocols—keeps development experience predictable.

Practical wrap-up

If you need kw to be generated when missing, do that at runtime with your decorator. To improve the developer experience in Pylance, express the optionality via overloads on a Protocol and have your decorator return a callable typed as the “defaulted” Protocol. Avoid attempting to insert parameters inside a ParamSpec sandwich; that shape isn’t supported. If kw can be positional, you might have more room, but keyword-only parameters remain constrained. When in doubt, lean on overloads to present multiple call shapes, and keep the runtime wrapper simple.