Skip to content

Type narrowing not propagated through method call with raise statements for class fields #20607

@phyalexander

Description

@phyalexander

Description

Mypy does not recognize type narrowing when validation (via raise if None) is extracted into a separate method. This forces users to duplicate checks locally (e.g., with assert or re-assigning variables) to satisfy the type checker, violating DRY and complicating code refactoring. The issue occurs even though the validation method ensures fields are not None at runtime before proceeding.

This is similar to issues like #11475 (isinstance narrowing ignored in extra functions), but specifically for class methods validating multiple instance fields without passing them as arguments. Workarounds like returning narrowed types or using TypeGuard are cumbersome here, as they require restructuring the code (e.g., returning tuples or protocols), and may introduce bugs (as noted in #16618 for TypeGuard on methods).

It could be an annoying problem when developing classes with non-trivial logic of state changes or patter "Builder".

Minimal Reproducible Example

Here's a minimal example demonstrating the problem. The _validate_fields method raises if any field is None, ensuring they are int afterward. However, mypy does not narrow the types in the calling method.

class TestMypyAbilities:
    _field1: int | None
    _field2: int | None
    _field3: int | None

    def __init__(self) -> None:
        self._field1 = None
        self._field2 = None
        self._field3 = None

    def _validate_fields(self) -> None:
        if self._field1 is None:
            raise RuntimeError("field1 is None")
        if self._field2 is None:
            raise RuntimeError("field2 is None")
        if self._field3 is None:
            raise RuntimeError("field3 is None")

    def some_method(self, x: int) -> int:
        self._validate_fields()  # Should narrow _field1, _field2, _field3 to int
        # Mypy should infer: self._field1: int, etc.
        return self._field1 + self._field2 + self._field3 + x

Actual Behavior

Running mypy demo.py (or mypy --strict demo.py) produces errors, as if narrowing didn't occur:

demo.py:22: error: Unsupported operand types for + ("int" and "None")  [operator]
demo.py:22: error: Unsupported operand types for + ("None" and "int")  [operator]
demo.py:22: error: Unsupported left operand type for + ("None")  [operator]
demo.py:22: note: Both left and right operands are unions
Found 3 errors in 1 file (checked 1 source file)

Possible ad hoc fixes

The simples solution is

def some_method(self, x: int) -> int:
    self._validate_fields()

    # duplicating validation logics
    assert self._field1 is not None
    assert self._field2 is not None
    assert self._field3 is not None
    
    return self._field1 + self._field2 + self._field3 + x

but this or returning a tuple of narrowed fields from the method) work but add boilerplate and reduce code maintainability.

Expected Behavior

Mypy should recognize the narrowing after the method call and not report operator errors, treating the fields as int post-validation (similar to local if raise statements).

Additional Notes

This was tested with default mypy config; no custom plugins or flags change the behavior.

Versions

  • mypy: 1.15.0 (compiled: yes)
  • Python: 3.14.2
  • OS: Tested on macOS and Linux (Fedora)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions