A module-level Final cannot be rebound, so the value it is initialized with is the value of every read of that variable. mypy only infers that literal when the annotation is omitted: with an explicit annotation the declared type wins, so the statically known value is lost as soon as the variable is read from another module, where there is no code flow information to narrow it.
# flags.py
import sys
from typing import Final
IS_WIN: Final[bool] = sys.platform == "win32"
NUM: Final[int] = 1
# main.py
from flags import IS_WIN, NUM
reveal_type(IS_WIN) # bool, could be "Literal[False]?"
reveal_type(NUM) # int, could be "Literal[1]?"
def expect_false(x: Literal[False]) -> None: ...
expect_false(IS_WIN) # error: Argument 1 has incompatible type "bool"; expected "Literal[False]"
Repro
final9.py:
import sys
from enum import Enum
from typing import Final
IS_WIN: Final[bool] = sys.platform == "win32"
IS_WIN_INFERRED: Final = sys.platform == "win32"
MAYBE_WIN: bool = sys.platform == "win32"
NUM: Final[int] = 1
NAME: Final[str] = "hi"
ITEMS: Final[list[int]] = []
class Color(Enum):
RED = 1
COLOR: Final[Color] = Color.RED
class Base: ...
class Sentinel(Base): ...
NO_INFO: Final[Base] = Sentinel()
class ClassWithFinal:
class_final: Final[int] = 1
final10.py:
import final9
from typing import Literal
from final9 import COLOR, IS_WIN, IS_WIN_INFERRED, ITEMS, MAYBE_WIN, NAME, NO_INFO, NUM, ClassWithFinal
reveal_type(IS_WIN)
reveal_type(IS_WIN_INFERRED)
reveal_type(final9.IS_WIN)
reveal_type(MAYBE_WIN)
reveal_type(NUM)
reveal_type(NAME)
reveal_type(COLOR)
reveal_type(NO_INFO)
reveal_type(ITEMS)
reveal_type(ClassWithFinal.class_final)
x: Literal[1] = NUM
y: Literal["hi"] = NAME
z: Literal[False] = IS_WIN
Current output with mypy --platform linux final9.py final10.py (master, 2.5.0+dev):
final10.py:5: note: Revealed type is "bool"
final10.py:6: note: Revealed type is "bool"
final10.py:7: note: Revealed type is "bool"
final10.py:8: note: Revealed type is "bool"
final10.py:9: note: Revealed type is "int"
final10.py:10: note: Revealed type is "str"
final10.py:11: note: Revealed type is "final9.Color"
final10.py:12: note: Revealed type is "final9.Base"
final10.py:13: note: Revealed type is "list[int]"
final10.py:14: note: Revealed type is "int"
final10.py:16: error: Incompatible types in assignment (expression has type "int", variable has type "Literal[1]")
final10.py:17: error: Incompatible types in assignment (expression has type "str", variable has type "Literal['hi']")
final10.py:18: error: Incompatible types in assignment (expression has type "bool", variable has type "Literal[False]")
The same output is produced for reads inside final9.py itself: mypy does not narrow a module-level Final to the literal value in its own module either.
Expected behavior
| declaration |
resolved type |
IS_WIN: Final[bool] = sys.platform == "win32" |
Literal[False]? |
IS_WIN_INFERRED: Final = sys.platform == "win32" |
Literal[False]? |
final9.IS_WIN, from final9 import IS_WIN |
Literal[False]? |
NUM: Final[int] = 1 |
Literal[1]? |
NAME: Final[str] = "hi" |
Literal['hi']? |
COLOR: Final[Color] = Color.RED |
Literal[Color.RED]? |
MAYBE_WIN: bool = sys.platform == "win32" |
bool |
NO_INFO: Final[Base] = Sentinel() |
Base |
ITEMS: Final[list[int]] = [] |
list[int] |
ClassWithFinal.class_final: Final[int] = 1 |
int |
(Literal[...]? is mypy's existing spelling for an Instance that remembers its value through last_known_value; Final = 1 without an annotation already resolves that way, so the annotated form would simply match it.)
Only literal values should be exposed. Widening any other inferred type would leak a subclass into everything inferred from such a read, which is why NO_INFO: Final[Base] = Sentinel() keeps Base. Class-body and self Finals keep their declared type, since a constructor can still assign them.
What mypy already has
Var.final_value is recorded during semantic analysis for a Final[X] = <constant> declaration, but it never reaches the type, so it is only used for literal_hash/binder keys.
Instance.last_known_value is the existing "declared type plus known value" mechanism: Final = 1 gets it from infer_literal_expr_type, and coerce_to_literal() (mypy/typeops.py) is what turns it back into a LiteralType when a literal is required.
mypy/reachability.py already evaluates sys.platform and sys.version_info comparisons, but only for branch pruning during semantic analysis, never for types. So Final[bool] = sys.platform == "win32" needs the same evaluation (infer_condition_value) to fold to Literal[False] — restricted to ALWAYS_TRUE/ALWAYS_FALSE, because TYPE_CHECKING and friends are true only for mypy while being false at runtime.
A prototype that stores the value in Instance.last_known_value for annotated module-level Finals in TypeChecker.check_assignment keeps mypy/test/testcheck.py green and mypy's self check clean; the bare Final = <statically known bool expression> form needs the same treatment after the type is inferred, since the declared type is not available at that point.
Not covered by this request
Exposing the literal does not make mypy prune branches that depend on it: FLAG: Final = False followed by if FLAG: is still analyzed today, and stays analyzed with the literal exposed, because truthiness narrowing does not consult last_known_value. That is the reachability half of the problem, tracked in #14604.
Related issues / prior art
Things to be aware of
- Two tests currently pin the opposite policy and would need updating:
testLiteralFinalDirectInstanceTypesSupersedeInferredLiteral (test-data/unit/check-literal.test) and testLiteralFineGrainedChainedViaFinal (test-data/unit/fine-grained.test).
docs/source/literal_types.rst says the context-sensitive behavior applies when no explicit type is given; that sentence would need to mention module-level Finals.
- Filling in
last_known_value has a visible side effect: ==/!= against such a variable start propagating the constant (x: Literal["hi", "bye"]; if x == NAME: narrows to Literal['hi']), since narrow_type_by_identity_equality coerces the target through coerce_to_literal().
Update: measured against the platform-check conformance tree
A cross-checker conformance tree now measures this question case by case: https://github.com/trim21/typing-platform-checks, with the full table in RESULTS.md. Each pass/ case must produce zero errors and each fail/ case at least one, on both a Linux and a Windows target; the cases use a Windows-only API on Linux and a POSIX-only API on Windows, so an unpruned branch is always observable.
For mypy master (5dc284074, mypy 2.5.0+dev) and for an implementation of the change proposed here:
| State |
pass/ satisfied |
fail/ satisfied |
mypy master |
1 / 13 |
4 / 4 |
| mypy with the literal value exposed |
4 / 13 |
4 / 4 |
Three cases flip: case10_type_annotated_final, case12_type_import_annotated_final, case13_type_attr_annotated_final — the annotated form, read in the same module, through an import, and through a module attribute. No fail/ case is lost, so the change does not trade a real error for silence. For comparison the same tree scores pyright 6/13 → 8/13 (microsoft/pyright#12000) and ty 9/13 → 13/13 (astral-sh/ruff#30255).
The tree splits the remaining pass/ failures into two capabilities: A, the literal value reaches the type of the name, and B, reachability honours that value so the guarded branch is not analyzed. This issue is about A only; B is #14604. Within A there are two spellings, and only the first one is covered by the measurement above:
- annotated
Final[bool] = <static platform or version check> — what this issue describes;
- bare
Final = <static platform or version check> — the initializer is checked before the variable's type exists, so nothing is recorded yet and the name stays bool (case9_type_bare_final, case11_type_import_bare_final). Small, but a distinct change in TypeChecker.check_assignment, not a variation of the annotated path.
For reference, an implementation of the annotated part was opened as #22428 and closed again. Its diff is the prior art for this issue: a pair of helpers in mypy/checker.py called from the annotated-assignment path, new cases in check-final.test, the two expectations in check-literal.test / fine-grained.test that pin the old "declared type wins" policy, and a paragraph in docs/source/literal_types.rst. It keeps the full test suite green and produces the measurement above.