From 1a5c0b0c4168a4d5de5a33b4832a3e77bc7b67af Mon Sep 17 00:00:00 2001 From: mokashang Date: Tue, 13 Oct 2026 09:31:16 -0700 Subject: [PATCH] Dedup overlapping union items in error messages When a type alias in a Union resolves to another Union with members that already appear in the outer Union (for example, ``TextInput | ImageInput`` where ``TextInput = str`` and ``ImageInput = str | None``), the error message rendered as ``str | str | None``. The dedup pass in ``ignore_last_known_values`` only compared Instance items against each other and left anything wrapped in a TypeAliasType in place, so the nested ``str`` from ``ImageInput`` slipped past it. Flatten the input list with ``flatten_nested_unions`` first, keeping recursive aliases intact (otherwise ``get_proper_type`` recurses forever), and look at the proper type of each item when deciding whether it is an Instance to deduplicate. Non-Instance items are preserved as before. Fixes #22194 --- mypy/messages.py | 15 ++++++++++++--- test-data/unit/check-python312.test | 13 +++++++++++++ test-data/unit/check-type-aliases.test | 21 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/mypy/messages.py b/mypy/messages.py index b58c9e7ac4b6c..c85130acfab10 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -3521,11 +3521,20 @@ def ignore_last_known_values(t: UnionType) -> Type: last_known_values are kept during union simplification, but may cause weird formatting for e.g. tuples of literals. """ + # Flatten nested unions reached through type aliases so an item like + # ``AnotherAlias`` (where ``AnotherAlias = str | None``) does not + # bypass deduplication just because it is wrapped in a TypeAliasType. + # Keep recursive aliases intact so we don't try to expand them forever. + items = flatten_nested_unions(t.items, handle_recursive=False) union_items: list[Type] = [] seen_instances = set() - for item in t.items: - if isinstance(item, ProperType) and isinstance(item, Instance): - erased = item.copy_modified(last_known_value=None) + for item in items: + if isinstance(item, TypeAliasType) and item.is_recursive: + union_items.append(item) + continue + proper = get_proper_type(item) + if isinstance(proper, Instance): + erased = proper.copy_modified(last_known_value=None) if erased in seen_instances: continue seen_instances.add(erased) diff --git a/test-data/unit/check-python312.test b/test-data/unit/check-python312.test index 8115bfb65e428..a7e9de3477fa2 100644 --- a/test-data/unit/check-python312.test +++ b/test-data/unit/check-python312.test @@ -2282,6 +2282,19 @@ if isinstance(x, tuple): [builtins fixtures/tuple.pyi] [typing fixtures/typing-full.pyi] +[case testPEP695TypeAliasUnionErrorDeduplicatesOverlappingItems] +# Regression test for https://github.com/python/mypy/issues/22194 +# Overlapping aliases should not render as e.g. "str | str | None". +type TextInput = str +type ImageInput = str | None +type SingleInput = TextInput | ImageInput + +def f(x: SingleInput) -> None: ... + +f(1) # E: Argument 1 to "f" has incompatible type "int"; expected "str | None" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + [case testPEP695MultipleTypeVarTuples] from typing import Generic from typing_extensions import TypeVarTuple, Unpack diff --git a/test-data/unit/check-type-aliases.test b/test-data/unit/check-type-aliases.test index 3f712eae7c54d..505762367055e 100644 --- a/test-data/unit/check-type-aliases.test +++ b/test-data/unit/check-type-aliases.test @@ -1449,3 +1449,24 @@ Alias1: TypeAlias = Union[C, int] class SomeClass: pass [builtins fixtures/tuple.pyi] + +[case testUnionTypeAliasErrorDeduplicatesOverlappingItems] +# Regression test for https://github.com/python/mypy/issues/22194 +# Overlapping aliases should not render as e.g. "str | str | None". +from typing_extensions import TypeAlias + +TextInput: TypeAlias = str +ImageInput: TypeAlias = str | None +SingleInput: TypeAlias = TextInput | ImageInput + +def f(x: SingleInput) -> None: ... + +f(1) # E: Argument 1 to "f" has incompatible type "int"; expected "str | None" + +Opt: TypeAlias = int | None +U: TypeAlias = int | Opt + +def g(x: U) -> None: ... + +g("x") # E: Argument 1 to "g" has incompatible type "str"; expected "int | None" +[builtins fixtures/tuple.pyi]