2025, Oct 26 01:00
Mypy, Literal types and membership tests: why narrowing fails and safer ways to structure code
Learn why mypy won't narrow str to Literal via list or tuple membership, why this is unsound, how pyright differs, and which safe patterns to use instead.
Static checkers make code safer, but they also enforce precise rules. A common surprise is when a membership guard like a string being in a small set of allowed values fails to convince mypy that the value is a specific Literal. The result is an error even though, at runtime, the branch looks airtight.
Minimal example
from typing import Literal
def accept_token(tok: Literal["foo", "bar"]) -> None:
    print(f"{tok=}")
def guarded_call(raw: str) -> None:
    if raw in ["foo", "bar"]:
        accept_token(raw)
    else:
        print("format incorrect")
Despite the membership test, mypy reports that the argument has type str and is not narrowed to Literal["foo", "bar"].
What is going on
The core reason is that mypy does not treat the expression value in ["literal1", "literal2"] as a type narrowing expression. Within the guarded branch, the variable still has type str. This is easy to see with a diagnostic:
def guarded_call(raw: str) -> None:
    if raw in ["foo", "bar"]:
        reveal_type(raw)  # `str`, not `Literal["foo", "bar"]`
There is an open request to support this form of narrowing, but today mypy does not perform it. The hesitation is not only about missing implementation; such narrowing is theoretically unsound. A Literal like Literal['foo'] corresponds to exactly one value, the concrete string 'foo' whose type is str. A membership check against containers of strings can pass for values that behave like 'foo' but are not that exact value of type str, for example instances of subclasses overriding equality and hashing.
class QuasiFoo(str):
    def __eq__(self, other):
        return type(other) is str and other == "foo"
    def __hash__(self):
        return hash("foo")
print(QuasiFoo() in ["foo", "bar"])   # True
print(QuasiFoo() in ("foo", "bar"))   # True
print(QuasiFoo() in {"foo", "bar"})   # True
Because a check like raw in ["foo", "bar"] accepts such values, narrowing to Literal["foo", "bar"] would not be sound in general.
Resolution
Given current mypy behavior, the branch does not narrow raw to a Literal, so passing it to a function that requires Literal["foo", "bar"] triggers a type error. The type remains str inside the guarded block, which explains the diagnostic. There is an open issue asking for support of this form of narrowing, but as of now it is not available.
One more detail that often comes up in teams using multiple checkers: pyright narrows in a similar pattern when using a tuple of literals, for example if raw in ("foo", "bar"):. This difference does not change mypy’s current behavior; it simply highlights that type-narrowing rules vary across tools.
Why this matters
Literal types, exhaustiveness checking and type narrowing are essential when you model finite protocols, flags or configuration values. Assuming that a membership test guarantees a Literal can lead to mismatches between your mental model and what the checker enforces. Understanding the unsoundness with subclassed strings helps explain why mypy is conservative here. It also underscores the importance of being aware of differences between static analyzers: a pattern accepted by one tool might not be recognized by another.
Takeaways
Do not rely on list, tuple or set membership to produce Literal narrowing in mypy; within the guarded block, the value stays as str. If your function truly requires a Literal, structure your code around type-narrowing expressions that mypy supports, or follow the open request for this feature to track progress. If you use multiple type checkers in your workflow, verify how each one treats Literal narrowing in membership tests to avoid surprises.