Search code examples
pythonpython-3.xpython-typingpylance

Narrow Union by shape


How to narrow Union by shape? I don't want to check actual types with isinstance or manual casting (there are a lot of types). Also I can't modify type definitions.

class X:
    title = "1"
class Y:
    name = "2"
class Z:
    name = "3"

for (i, r) in enumerate([X(), Y(), Z()]): # type of r: X | Y | Z

    if hasattr(r, "title"):
        print(r.title) # error
    else:
        print(r.name)  # error

Type check error says:

(variable) title: str | Unknown
Cannot access member "title" for type "Y"
  Member "title" is unknown
Cannot access member "title" for type "Z"
  Member "title" is unknown

(variable) name: Unknown | str
Cannot access member "name" for type "X"
  Member "name" is unknown

Solution

  • The issue

    In a way Pylance checker is correct.

    The types are unrelated. There would be no check errors for related types:

    class Base:
        title: str
        name: str
    
    class X(Base):
        title = "1"
    class Y(Base):
        name = "2"
    class Z(Base):
        name = "3"
    
    for (i, r) in enumerate([X(), Y(), Z()]): # type of r: X | Y | Z
    
        if hasattr(r, "title"):
            print(r.title) # no error
        else:
            print(r.name) # no error
    

    Also, duck typing is not applicable here since the set of members is different. There would be no errors if types were considered equivalent:

    class X:
        name: str
        title = "1"
    class Y:
        name = "2"
        title: str
    class Z:
        name = "3"
        title: str
    
    for (i, r) in enumerate([X(), Y(), Z()]): # type of r: X | Y | Z
    
        if hasattr(r, "title"):
            print(r.title) # no error
        else:
            print(r.name) # no error
    

    Workaround

    Although you can trick the Pylance checker so it won't complain about the code, there are a couple of options:

    Option 1. Suppress type checks

    class X:
        title = "1"
    class Y:
        name = "2"
    class Z:
        name = "3"
    
    for (i, r) in enumerate([X(), Y(), Z()]): # type of r: X | Y | Z
    
        if hasattr(r, "title"):
            print(r.title) # type: ignore
        else:
            print(r.name) # type: ignore
    
    

    Option 2. Use dynamic access with getattr instead of direct member access

    class X:
        title = "1"
    class Y:
        name = "2"
    class Z:
        name = "3"
    
    for (i, r) in enumerate([X(), Y(), Z()]): # type of r: X | Y | Z
    
        if hasattr(r, "title"):
            print(getattr(r, "title"))
        else:
            print(getattr(r, "name"))