How Pyrefly Handles ParamSpec and TypeVarTuple for Callable Generics

Pyrefly converts ParamSpec and TypeVarTuple into specialized TypeVarType nodes within its Typed Syntax Process (TSP) representation, storing them as parameter packs in CallableType structures to support variadic generic callable signatures.

Pyrefly, Facebook's open-source Python type checker, implements PEP 612's callable generic constructs through a multi-stage pipeline that transforms Python AST nodes into a solver-friendly intermediate graph. When analyzing Callable types containing ParamSpec or TypeVarTuple, the type-checker flags these variables in pyrefly/lib/tsp/type_conversion.rs and later records them as bindings inside CallableType structures defined in pyrefly/lib/binding/callable.rs.

Converting ParamSpec to TSP

Pyrefly distinguishes between a ParamSpec variable itself and its placeholder value inside a Callable signature.

ParamSpec Variable Conversion

In pyrefly/lib/tsp/type_conversion.rs, the compiler matches the PyreflyType::ParamSpec variant and constructs a DeclaredType::TypeVarType with the is_param_spec flag enabled:

// pyrefly/lib/tsp/type_conversion.rs (≈ line 283)
match pyrefly_type {
    PyreflyType::ParamSpec(ps) => {
        TypeVarType::new(ps.name.clone(), /* is_param_spec = true */)
    }
    // ...
}

This flag tells the solver that this type variable represents a parameter pack rather than a single type.

ParamSpecValue Placeholder

When ParamSpec appears as the parameter list of a Callable (forming a ParamSpecValue), the converter maps it to a builtin type placeholder:

// pyrefly/lib/tsp/type_conversion.rs (≈ line 344)
PyreflyType::ParamSpecValue(_) => builtin("ParamSpec"),

The solver recognizes this builtin name and expands it when resolving concrete call sites.

TypeVarTuple Implementation

TypeVarTuple follows an analogous conversion path but uses a variadic-specific flag. The same conversion function in pyrefly/lib/tsp/type_conversion.rs handles this variant:

// pyrefly/lib/tsp/type_conversion.rs (≈ line 345)
PyreflyType::TypeVarTuple(tv) => {
    TypeVarType::new(tv.name.clone(), /* is_type_var_tuple = true */)
}

The is_type_var_tuple marker enables the solver to treat the variable as a variadic tuple that can be unpacked with the * or Unpack operator.

Building Generic Callable Signatures

Once converted, these type variables participate in CallableType construction within pyrefly/lib/binding/callable.rs.

CallableType Construction

The callable builder walks the parameter list and, upon encountering a ParamSpec or TypeVarTuple, records a ParamSpecBinding or TypeVarTupleBinding inside the CallableType structure:

  • ParamSpecBinding: Captures the remaining arguments of a callable after any Concatenate prefix.
  • TypeVarTupleBinding: Represents a variadic argument pack that can be substituted with a concrete tuple of types.

Parameter Pack Substitution

During type solving (located in pyrefly/lib/solver/), the engine substitutes these bindings when it encounters a concrete call. For a ParamSpec, the solver unpacks the captured argument types into the callable's parameter list. For a TypeVarTuple, it expands the variadic tuple into individual positional arguments.

Error Handling and Validation

Pyrefly emits precise diagnostics when these constructs appear in invalid contexts. If a ParamSpec or TypeVarTuple is used where a concrete type is required (for example, as a generic argument to a non-callable), the type-checker raises an error such as E: ParamSpec is not allowed in this context.

Test coverage in pyrefly/lib/test/paramspec.rs and pyrefly/lib/test/type_var_tuple.rs asserts these validation rules, ensuring that variadic type variables cannot be instantiated with incompatible types or used outside callable signatures.

Practical Code Examples

The following patterns demonstrate how these internal mechanisms surface in user code:


# Example 1: ParamSpec with Concatenate

from typing import Callable, ParamSpec, Concatenate

P = ParamSpec('P')

def wrapper(f: Callable[Concatenate[int, P], int]) -> Callable[P, int]:
    def inner(*args: P.args, **kwargs: P.kwargs) -> int:
        return f(42, *args, **kwargs)
    return inner

# Pyrefly records:

# - A CallableType with a leading fixed `int` argument

# - A ParamSpecBinding capturing the remaining `P` parameters

# Example 2: TypeVarTuple with Unpack

from typing import Callable, TypeVarTuple, Unpack

Ts = TypeVarTuple('Ts')

def apply_args(f: Callable[[Unpack[Ts]], int], *args: Unpack[Ts]) -> int:
    return f(*args)

# Pyrefly treats `Ts` as a variadic TypeVarType;

# the Callable becomes a generic that accepts any tuple of argument types.

Summary

Frequently Asked Questions

What is the difference between ParamSpec and TypeVarTuple in Pyrefly's implementation?

ParamSpec captures the shape of a function's parameter list (both positional and keyword arguments), while TypeVarTuple captures a variadic sequence of individual types. Internally, ParamSpec triggers the is_param_spec flag and creates a ParamSpecBinding, whereas TypeVarTuple sets is_type_var_tuple and creates a TypeVarTupleBinding. The former is used to preserve argument names and kinds; the latter treats arguments as a homogeneous or heterogeneous tuple.

How does Pyrefly prevent misuse of ParamSpec in non-callable contexts?

According to pyrefly/lib/test/paramspec.rs, the type-checker emits specific diagnostics when a ParamSpec appears where a concrete type is expected, such as in a generic class parameter list or as a variable annotation outside a Callable. The validation logic checks the is_param_spec flag during type resolution and raises an error if the context does not support parameter packs.

Where does the actual parameter substitution happen for generic callables?

Substitution occurs in the solver modules under pyrefly/lib/solver/. When the type-checker encounters a call to a generic callable containing a ParamSpec or TypeVarTuple, it matches the provided arguments against the CallableType's bindings. The solver then creates a substitution map that replaces the variadic type variables with the concrete argument types or tuples inferred from the call site.

Does Pyrefly support Concatenate and Unpack operators?

Yes. According to the callable binding logic in pyrefly/lib/binding/callable.rs, Pyrefly supports typing.Concatenate for prepending fixed arguments to a ParamSpec, and typing.Unpack (or the * syntax) for expanding TypeVarTuple instances. The parser recognizes these operators during AST construction, and the type converter preserves them as modifiers on the CallableType before the solver performs final expansion.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →