ty icon indicating copy to clipboard operation
ty copied to clipboard

Enforce the Liskov Substitution Principle for non-methods

Open AlexWaygood opened this issue 6 days ago • 1 comments

We have implemented a basic version of the Liskov Substitution Principle to cover cases where methods are incompatibly overridden in subclasses. We also need to implement separate diagnostics to cover properties incompatibly overridden in subclasses, and mutable attributes incompatibly overridden in subclasses.

The reason why these are planned as separate diagnostics is because a large amount of code in the ecosystem unsoundly overrides mutable attributes covariantly, e.g.

class A:
    x: int

class B(A):
    x: bool

Partly this is because mypy allowed this for years -- and still does, unless users explicitly opt into the mutable-override error code. Implementing these as separate diagnostics to our existing invalid-method-override diagnostic will therefore allow users to switch the mutable-attribute-override rule off specifically if it causes a large number of diagnostics on their code.


Sub-tasks (many of these may share a common implementation):

  • [ ] Enforce Liskov for property types: this should cause us to emit a diagnostic:

    class A:
        @property
        def f(self) -> int:
            return 42
    
    class B(A):
        @property
        def f(self) -> str:  ❌ Superclass returns `int`, subclass returns `str`, `str` is not a subtype of `int`
            return "42"
    
  • [ ] Enforce that a writable attribute cannot be overridden with a read-only property:

    class A:
        x: int
    
    class B(A):
        @property
        def x(self) -> int:  ❌ Superclass attribute is writable, subclass attribute is read-only
            return 42
    

    and

    class A:
        @property
        def x(self) -> int:
            return 42
    
        @x.setter
        def x(self, value: int) -> None: ...
    
    class B(A):
        @property
        def x(self) -> int:  ❌ Superclass attribute is writable, subclass attribute is read-only
            return 42
    
  • [ ] Enforce Liskov for attribute types:

    class A:
        x: int
    
    class B(A):
        x: bool  ❌ Type of `x` attribute is invariant because it is mutable
    
  • [ ] Enforce that a non-Final attribute cannot be overridden with a Final one

    from typing import Final
    
    class A:
        x: int
    
    class B(A):
        x: Final[int]  ❌ Superclass attribute is writable, subclass attribute is read-only
    
  • [ ] Enforce that a non-ClassVar attribute cannot be overridden with a ClassVar attribute:

    from typing import ClassVar
    
    class A:
        x: int
    
    class B(A):
        x: ClassVar[int]  ❌ Superclass attribute is writable on instances, subclass attribute is not
    
  • [ ] Enforce that a ClassVar attribute cannot be overridden with an implicit instance attribute, since ClassVars can be mutated on the class object itself but implicit instance attributes cannot:

    from typing import ClassVar
    
    class A:
        x: ClassVar[int]
    
    class B(A):
        def __init__(self):
            self.x: int  ❌ Superclass attribute is writable on the class object, subclass attribute is not
    
  • [ ] Enforce that a non-ReadOnly TypedDict field cannot be overridden with a ReadOnly TypedDict field:

    from typing import TypedDict, ReadOnly
    
    class A(TypedDict):
        x: int
    
    class B(A):
        x: ReadOnly[int]  ❌ Superclass field is writable, subclass attribute is read-only
    

AlexWaygood avatar Dec 22 '25 13:12 AlexWaygood