Skip to content

Return-context TypeVar inference silently resolves to Any where argument-only inference errors #21875

Open

Description

@goustaff

Bug Report

When a generic function solves a TypeVar from its first positional argument and propagates it (invariantly) to *args, mypy correctly rejects a call where the arguments disagree — unless that same call sits in a position with an expected type of Sequence[SomeGeneric[Any]] (e.g. returned inside a list literal from an annotated function).

In that context, mypy silently resolves the TypeVar to Any instead of reporting the conflict, using the exact same fallback type it otherwise reports as an error one call-shape away.

To Reproduce

from typing import Any, Generic, Sequence, TypeVar

T = TypeVar("T", bound="Base")


class Base:
    pass


class A(Base):
    pass


class B(Base):
    pass


class Box(Generic[T]):
    def __init__(self, value: T) -> None:
        self.value = value


class Result(Generic[T]):
    def __init__(self, value: T) -> None:
        self.value = value


def combine(first: Box[T], *rest: Box[T]) -> Result[T]:
    return Result(first.value)


mismatched = combine(Box(A()), Box(B()))  # EXPECTED: errors here...
reveal_type(mismatched)  # ... Undefined behavior (?): but Revealed type is "Result[Any]"


def scenario() -> Sequence[Result[Any]]:
    return [reveal_type(combine(Box(A()), Box(B())))]  # BUG: 0 error

A and B are unrelated siblings of Base — neither is a subtype of the other, so no valid T exists for either call. Both calls are identical; only the surrounding context differs.

Expected Behavior

Both calls should be rejected the same way, since neither actually has a valid T. At minimum, the second call shouldn't pass silently just because it happens to sit inside a Sequence[Result[Any]]-typed return.

Actual Behavior

main.py:34: error: Cannot infer value of type parameter "T" of "combine"  [misc]
main.py:35: note: Revealed type is "Result[Any]"
main.py:39: note: Revealed type is "Result[Any]"
Found 1 error in 1 file (checked 1 source file)
  • Line 34 (bare assignment, no expected-type context): mypy detects the conflict and errors — but still assigns a concrete type to the LHS rather than Never/an error type. reveal_type on line 35 shows it fell back to Result[Any].
  • Line 39 (identical call, nested in a list literal returned from a function annotated -> Sequence[Result[Any]]): mypy uses that outer expected type as extra context while solving T, lands on T=Any, and the exact same argument conflict as line 34 goes completely unreported. reveal_type (transparent to inference, so it doesn't perturb the context) confirms it's the same Result[Any] fallback — just silent this time.

So mypy's own error-recovery type for the unsolvable case (Any) is exactly the value that lets the same construct through unchecked one call shape later, purely because of where the call happens to be textually nested.

Cross-checked against pyright/Pylance on the same standalone call (mismatched = combine(Box(A()), Box(B()))), basic mode:

error: Argument of type "Box[B]" cannot be assigned to parameter "rest" of type "Box[T@combine]" in function "combine"
  "Box[B]" is incompatible with "Box[A]"
    Type parameter "T@Box" is invariant, but "B" is not the same as "A"
information: Type of "mismatched" is "Result[A]"

pyright anchors T on the first argument (T=A) for the bare-assignment case and correctly flags the incompatible rest argument with a precise, per-argument diagnostic — better than mypy's vaguer "cannot infer" here. But on the second case — the actual one this report is about — pyright has the exact same hole: no error, and it resolves the call to Result[Any] too. So this isn't a mypy-only defect — it looks like a blind spot shared by both implementations specifically when an invariant TypeVar's resolution is handed off to an Any-containing expected-type context, even though both tools correctly catch the identical conflict when no such context is present.

Your Environment

  • Mypy version used: 2.3.1
  • Mypy command-line flags: --strict (also reproduces without it)
  • Mypy configuration options from mypy.ini (and other config files): none — reproduces from a bare mypy repro.py
  • Python version used: 3.14

Synthesis

mismatched = combine(Box(A()), Box(B()))   # mypy errors: cannot infer T
return [combine(Box(A()), Box(B()))]       # identical call, no error, inside `-> Sequence[Result[Any]]`

T is solved invariantly from two occurrences of Box[T] with incompatible types. Alone,
mypy correctly rejects the call. Nested in a list literal flowing into an annotated
Sequence[Result[Any]] return, the identical call is silently accepted. Pyright shows the
same behavior.

Why

Both checkers do bidirectional ("local") type inference: a type is synthesized
bottom-up from an expression, but an expected type from the surrounding context (here,
the annotated return type) also flows top-down to help pick type arguments unification
alone can't resolve — the lineage of Pierce & Turner's local type inference (background
context, not a verified quote). That channel exists to disambiguate, not to
overrule a conflict synthesis already found — but here it does exactly that: the
arguments alone already produce an unsatisfiable constraint on T, yet the expected-type
channel reverses the verdict for the identical call.

The likely mechanism: gradual typing's consistency relation (~, connecting Any to
every type) is reflexive and symmetric but explicitly not transitive (Siek & Taha,
2006, confirmed against source) — Any ~ A and Any ~ B do not license A ~ B. The
symptom matches an inference pass that lets each occurrence of T unify against the
Any-containing expected type independently, without cross-checking that the two results
agree — i.e. treating a non-transitive relation as if it composed. (This mechanism is a
diagnostic hypothesis from the symptom, not confirmed by reading either checker's
constraint-solving source.)

Symptom-level evidence for the same conclusion: the identical call gets two different
verdicts based purely on syntactic nesting, which means the checker's merge of
"constraints from arguments" and "constraints from the expected type" isn't confluent —
and both mypy and Pyright hit the exact same hole, suggesting a structural gap in how
bidirectional inference commonly combines with gradual typing's Any, not a mypy-specific
slip.

Bottom line

A concrete instance of known friction between bidirectional/local type inference and
gradual typing's non-transitive Any relation — both major Python type checkers reverse an
otherwise-correct rejection purely based on surrounding syntactic context.


Playground

https://mypy-play.net/?mypy=latest&python=3.14&gist=23e464afa5e3dc1e6d4b7ee873419937

Activity

  1. goustaff commented on Aug 20, 2026

    @goustaff
    Author

    Actually, this bites us in practice (for a type-safe library we're writing).
    Happy to share more context or a more concrete, real-world example if that'd help track it down!

  2. A5rocks commented on Aug 20, 2026

    @A5rocks
    Collaborator

    This seems right to me. mypy is using the return type of scenario to infer T, and Any is a valid solution. It's not something mypy will do without you using an Any somewhere though.

  3. added
    topic-inferenceWhen to infer types or require explicit annotations
    pendingIssues that may be closed
    on Aug 20, 2026
  4. goustaff commented on Aug 21, 2026

    @goustaff
    Author

    This seems right to me. mypy is using the return type of scenario to infer T, and Any is a valid solution. It's not something mypy will do without you using an Any somewhere though.

    Hi!

    Even the "cannot infer" case assigns Result[Any].
    I would have expected the entire line to fail (though I don't know enough about MyPy's design choices to say for sure, but I would have expected a "Never" or something like that).

    In the nested case, silently picking T=Any isn't resolving ambiguity, but hiding the same failure.

    I can put any nonsense in a Box[T]/Box[T] mismatch as long as it's returned through an Any-shaped annotation, and nothing catches it.

  5. A5rocks commented on Aug 21, 2026

    @A5rocks
    Collaborator

    But it's picking T=Any because you guided it to? This is the same as looking at:

    from typing import Any
    
    a = [5]
    b = ["5"]
    c = list[list[Any]]([a, b])

    and saying that should error. That's just how gradual types work and you explicitly have to opt in by using them. To decrease any accidents, mypy has other rules which disallow Any in many places which you can enable.

  6. goustaff commented on Aug 21, 2026

    @goustaff
    Author

    But it's picking T=Any because you guided it to? This is the same as looking at:

    from typing import Any

    a = [5]
    b = ["5"]
    c = list[list[Any]]([a, b])
    and saying that should error. That's just how gradual types work and you explicitly have to opt in by using them. To decrease any accidents, mypy has other rules which disallow Any in many places which you can enable.

    You're right about the example provided, untouched.
    Since I wrote: def scenario() -> Sequence[Result[Any]]:

    But even if I simply write:
    def scenario():

    And let the inference works, it will compile without yelling at me even if I'm doing non-sense in combine.

    Starting from this minimal example: how could I write this better/have an even more strict static type checking?

    I'm from TypeScript and struggling a bit too (even if I think that mypy syntax is way cleaner than horrible hackish things in TS, but that's another topic).

    I tried to edit it in many ways and didn't find out the one which allows me to model it as type-safe I'd like.


    Also, the mismatched value being both infered as Result[Any] and both raising Cannot infer value of type parameter "T" of "combine" on the same assignation line is questioning me a lot.

    (L34)
    https://mypy-play.net/?mypy=latest&python=3.14&gist=23e464afa5e3dc1e6d4b7ee873419937

  7. goustaff commented on Aug 21, 2026

    @goustaff
    Author

    Following up with a more minimal repro that isolates the actual mechanism.
    The Any in the annotation (-> Sequence[Result[Any]]) isn't required.
    Also, putting explicitly the Any here means that we want the compiler to be "lazy", which is indeed the expected behavior.

    But this alone reproduces it, with zero Any written anywhere in the source:

    def scenario():
        return combine(Box(A()), Box(B()))  # no error, no list, no annotation

    Compare with the same call as a bare assignment in the same (unannotated) function body:

    def scenario():
        x = combine(Box(A()), Box(B()))  # correctly errors: "Cannot infer value of type parameter T"
        return x

    So it's not being inside a function, being inside a list, or the user opted into Any.
    It's specifically the return statement.

    Tested against mypy 2.3.1:

    • x = combine(...) with no context: constraints from first and *rest are unified jointly, conflict correctly detected.
    • return combine(...) or return [combine(...)], annotated or not: the expression is checked against the function's return type as an expected type context.
    • When unannotated, that's implicitly Any (I don't know a lot about how mypy does inference there, I just noticed it seems to be a bit tricky with generics), same as writing -> Any explicitly.

    Then, each occurrence of T unifies independently against Any, so the joint constraint (T must equal both A and B) doesn't occur. It's never evaluated at all.


    EDIT:

    While poking around in mypy's code, I think I noticed something that might be a lead (take this with a grain of salt, I don't know the codebase well).

    There's a TypeOfAny class in mypy/types.py that seems to tag each Any with where it came from:

    unannotated, explicit, from_unimported_type, from_omitted_generics, from_error, special_form
    

    This looks like it's used by flags such as --disallow-any-explicit.

    So, maybe that in a return statement, we don't distinguish between an Any the user actually wrote and an Any that was generated automatically (missing annotation, or as a result of an error, like Result[Any] here).

    That might explain the behavior in this issue: maybe only TypeOfAny.explicit should count as a valid solution when unifying a TypeVar across multiple arguments, and the other kinds shouldn't? I don't know.

  8. Phlogistique commented on Oct 21, 2026

    @Phlogistique
    Contributor

    A simpler repro that does not ask for Any:

    def concat[T](x: list[T], y: list[T]) -> list[T]:
        return x + y
    
    
    def noop[A](x: A) -> None:
        pass
    
    concat([3], "toto")  # error: Cannot infer value of type parameter "T" of "concat"
    noop(concat([3], ["toto"]))  # no error
  9. Phlogistique commented on Oct 21, 2026

    @Phlogistique
    Contributor

    And my actual motivation: I have an equivalent to asyncio.TaskGroup / trio's Nursery, with an add method that returns its argument for ergonomic reasons:

    class MyTaskGroup:
        def add[WorkerT: Worker](self, worker: WorkerT) -> WorkerT:
            ...
            return worker

    Then I have a helper to turn an arbitrary function plus an input queue into a worker:

    class Lambda[Input](Worker):
        def __init__(self, rx: Queue[Input | Sentinel], f: Callable[[Input], None]):
            ...

    The intent is to have the type system catch an error where the input queue type does not match the function type.
    It works if I do

    q = Queue[str | int | Sentinel]()
    def f(x: str) -> None: ...
    
    worker = Lambda(q, f)  # error: Cannot infer value of type parameter "Input"
    my_task_group.add(worker)

    But if I do

    worker = my_task_group.add(Lambda(q, f))

    The error is swallowed and I don't see the failure until runtime.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugmypy got something wrongpendingIssues that may be closedtopic-inferenceWhen to infer types or require explicit annotations

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions