Pylance requiring explicit type on conformant list variables

Question:

I define a type as a Union of Literal strings

Color = Literal[
    "red",
    "green",
    "blue",
    "yellow",
    "orange",
    "purple"
]

I have a function that expects a list of strings conforming to the type.

def f(colors: List[Color]):
    ...

I instantiate a list of conformant strings and pass it to the function

colors = ['blue', 'green']

f(colors)

Pylance red squiggles the function call with the alert

Argument of type "list[str]" cannot be assigned to parameter "colors" of type "List[Color]" in function "f"
  "list[str]" is incompatible with "List[Color]"
    TypeVar "_T@list" is invariant
      Type "str" cannot be assigned to type "Color"
        "str" cannot be assigned to type "Literal['red']" Pylance(reportGeneralTypeIssues)

The alert goes away if I explicitly annotate the instantiated list

colors: List[Color] = ['blue', 'green']

This seems redundant. The list matches the expected type regardless of if I annotate it. Shouldn’t the type system recognize that?

In fact, I can pass the list directly inplace with no alert

f(['blue', 'green']) # Pylance allows

Pylance is also fine with this

def f(seven: Literal[7]):
    ...
    
x = 7
f(x) # Pylance allows, doesn't require doing x: int = 7

So it seems to only complain about list variables, not implicitly typed variables in general.

Why must I explicitly annotate list variables whose values definitely conform to the expected type?

Asked By: Michael Moreno

||

Answers:

Python literals, like 'blue' or 7, have a natural type. 'blue' is a str, and 7 is an int, according to their runtime types. Type Literals can throw a little confusion into this, because while they take specific objects and give them additional semantic meaning (like 'blue' being a Color or 7 being parameter for f()), they do not override the default assumed type and are only locked in as the true type of the variable when necessary.

How does this result in the behavior you’ve encountered? Let’s deal with the 7 first. After x = 7, the implicit, natural type of x is not "a type compatible with f()", it’s int. That implicit type gets narrowed successfully during f(x), but only in the context of that exact call, and only because Pylance knows that x legitimately can be narrowed to 7.

Meanwhile, for colors = ['blue', 'green'], Pylance is only worried about coming up with the right type for colors. Based on the literal, it first sees that it’s a list, and then that it’s a list of strings. Thus the inferred type is list[str]. Now, can this be narrowed to list[Color] when we get to f(colors)? No, because lists are mutable. There’s no runtime guarantee that the list won’t end up with other strings, so the literals inside the list cannot be narrowed into a color. Again, to contrast, 7 is an immutable int, so we know there can’t be anything else getting stored alongside the 7 within x. Why then does f(['green', 'blue']) work? Because the lifecycle of the list in this case is only the function call itself, so there’s an implicit request to make the list compatible with the call signature if at all possible, without worrying about what the caller code might try to do to the list later (since it can’t do anything at all).

So how could this be bypassed? First is what you noticed, to explicitly limit the list to Colors. You could also use an immutable type, like a tuple, since Python knows, just like with the list made within the function call, that the object won’t change during runtime:

Color = Literal['red', 'blue']

def f(colors: Sequence[Color]): ...

L = ['red', 'blue']
f(L)  # fails!
T = ('red', 'blue')
f(T)  # succeeds!
Answered By: pydsigner