Skip to content

[1.16 regression] Explicit type declaration overridden/merged with Any #19034

Open

Description

@PeterJCLaw

Bug Report

It appears that explicit type definitions of loop variables can be overridden or merged with values inferred from the iterator. Unfortunately this happens even when the iterator type is Any, somewhat undermining the explicit definition.

To Reproduce

import itertools, typing

class ErrorType:
    pass

def report_errors(error_type: ErrorType, id_: object, messages: list[str]) -> None:
    pass

errors: list[typing.Any]

source: tuple[ErrorType, object] | None
for source, errors_group in errors:
    reveal_type(source)  # good: Union[tuple[demo.ErrorType, builtins.object], None]
                         # bad: Union[tuple[demo.ErrorType, builtins.object], Any]
    if source:
        reveal_type(source)  # good: tuple[demo.ErrorType, builtins.object]
                             # bad: Union[tuple[demo.ErrorType, builtins.object], Any]

        messages: list[str]
        report_errors(*source, messages)  # bad: Too many arguments for "report_errors"

I've tried reproducing this without the loop, however that seems to make the issue go away.

Removing the messages argument to report_errors also seems to avoid the "too many arguments" error, though that doesn't affect the revealed types so I've left it in to give a clearer pass/fail when running mypy.

Expected Behavior

Under 1.15 and up to 8dd616b the above code passes cleanly with the "good" noted revealed types.

Actual Behavior

From b50f3a1 onwards the above code fails type checking due to the errant message to the report_errors function, and outputs the "bad" noted revealed types. I'm not familiar enough with mypy to explain why that commit is the cause, however I have narrowed this to that commit using git bisect so I'm fairly confident those changes are related.

Your Environment

Versions as noted above. No command line flags or other config needed.

Python 3.12.3 on Ubuntu 24.04.

Activity

  1. sterliakov commented on May 7, 2025

    @sterliakov
    Collaborator

    Adding Any to union should be harmless, I think that's the desired outcome of #18538 (but let's ask @ilevkivskyi to confirm).

    However, the error on the last line is really bad - we should not reject func(*Any, arg) if func takes one or more arguments, and so a tuple[correct, length] | Any shouldn't be rejected either.

    In other words, this is a bug IMO:

    from typing import Any
    
    def report_errors(error_type: int, id_: str, messages: list[str]) -> None:
        pass
    
    any_: Any
    messages: list[str]
    report_errors(*any_)  # OK
    report_errors(*any_, messages)  # E: Too many arguments for "report_errors"  [call-arg]

    When there's Any unpacked in the arguments list, it can be something unpacking to the right number of arguments. Since Any is an escape hatch, that should be good enough - we aren't able to prove there's an error. Why do we reject such calls?

  2. PeterJCLaw commented on May 7, 2025

    @PeterJCLaw
    ContributorAuthor

    Thanks for looking at this, I'd be interested to hear more about what's expected here.

    Note that the addition of the Any to the union does also remove the None from it, which is there explicitly in the declaration. That also feels like it would lead to bugs that would have been caught previously.

    Even if adding Any to a union is normally ok, it's not clear to me that that is what should be happening here. The intent of the code is to declare an exact type, which is the modified implicitly in a manner which (to me) feels out of step with what happens elsewhere.

    Consider for example:

    foo: int | None
    def bar(): ...
    
    foo = bar()
    
    reveal_type(foo)    # previously: "Union[builtins.int, None]"
                        # now: "Union[builtins.int, Any]"
    reveal_type(bar())  # "Any"
    
    foo.bit_length()    # previously: error: Item "None" of "int | None" has no attribute "bit_length"  [union-attr]
                        # now: no error

    Which I think is equivalent -- the key thing is that previously the type explicitly declared was respected and this was a way to indicate what the type is locally when we know the return type even if it's not annotated. With the change, Any appears explicitly and replaces None. This seems likely to cause lots of false negatives which would previously have been caught.

    This does seem to be specific to the case of the union having None though. Testing with int | str in the above works as I'd expect -- the union remains just int | str throughout and the final line errors as expected.

  3. xyf5432 commented on Oct 20, 2025

    @xyf5432

    Hi, I've done some digging into this regression and wanted to share my findings.

    The issue seems to stem from changes to binder.assign_type introduced in commit b50f3a1 (as correctly identified by the issue reporter). The logic prior to this change was more state-aware. When assigning an Any value to a variable declared as a Union containing None, it would first check if the variable's current narrowed type was None. Only then would it allow None to be replaced with Any. Otherwise, the more precise declared type was preserved.

    However, the current logic no longer performs this check on the variable's narrowed type. Instead, it triggers whenever the declared type is a Union containing None, regardless of its current state. This causes it to incorrectly broaden a precise type like Union[SomeType, None] into Union[SomeType, Any], which leads to the subsequent failures.

    It seems this issue could be resolved by restoring the conditional check against most_recent_enclosing_type:

    and isinstance(
        get_proper_type(self.most_recent_enclosing_type(expr, NoneType())), 
        NoneType
    )

    I'm not sure what the developers' motivation was for the change, but its absence appears to be the root cause.

    Additionally, it appears the for loop isn't essential to the issue—the problem is rooted in the assignment logic itself. Here is a minimal, loop-free reproduction that isolates the behavior:

    import typing
    
    class ErrorType:
        pass
    
    def report_errors(error_type: ErrorType, id_: object, messages: list[str]) -> None:
        pass
    
    errors: typing.Any = ((ErrorType(), "some_id"), ["An error message"])
    
    source: tuple[ErrorType, object] | None
    errors_group: typing.Any
    
    # The problematic assignment from an `Any` source occurs here.
    (source, errors_group) = errors
    
    reveal_type(source)  # Revealed as Union[tuple[ErrorType, object], Any]
    
    if source:
        reveal_type(source) # Type narrowing fails.
        
        messages: list[str] = []
        report_errors(*source, messages)

    Hope this analysis is helpful!

  4. changed the title [-]Explicit type declaration overridden/merged with Any[/-] [+][1.16 regression] Explicit type declaration overridden/merged with Any[/+] on Dec 30, 2025
  5. added a commit that references this issue on Jan 17, 2026
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 wrongtopic-join-v-unionUsing join vs. using unions

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions