From fa592350fd635eb776c907e3fdfe914ee27028db Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Wed, 29 Apr 2026 11:19:42 +0100 Subject: [PATCH 01/24] Support nested special forms in functional syntax PEP 728 allows multiple special forms to be applied to a key when a TypedDict is created using functional syntax, but the logic was only checking and stripping a single instance. This prevents overriding the overall total status on a ReadOnly field. --- mypy/semanal_typeddict.py | 26 ++++++++++---------- test-data/unit/check-typeddict.test | 37 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/mypy/semanal_typeddict.py b/mypy/semanal_typeddict.py index 3655e4c89dd4b..476e5169b68f6 100644 --- a/mypy/semanal_typeddict.py +++ b/mypy/semanal_typeddict.py @@ -433,7 +433,7 @@ def check_typeddict( # This is a valid typed dict, but some type is not ready. # The caller should defer this until next iteration. return True, None, [] - name, items, types, total, tvar_defs, ok = res + name, items, wrapped_types, total, tvar_defs, ok = res if not ok: # Error. Construct dummy return value. if var_name: @@ -455,18 +455,18 @@ def check_typeddict( if name != var_name or is_func_scope: # Give it a unique name derived from the line number. name += "@" + str(call.line) - required_keys = { - field - for (field, t) in zip(items, types) - if (total or (isinstance(t, RequiredType) and t.required)) - and not (isinstance(t, RequiredType) and not t.required) - } - readonly_keys = { - field for (field, t) in zip(items, types) if isinstance(t, ReadOnlyType) - } - types = [ # unwrap Required[T] or ReadOnly[T] to just T - t.item if isinstance(t, (RequiredType, ReadOnlyType)) else t for t in types - ] + + # Unwrap special forms (Required/NotRequired/ReadOnly) + types: list[Type] = [] + required_keys: set[str] = set() + readonly_keys: set[str] = set() + for field, t in zip(items, wrapped_types): + unwrapped_type, is_required, is_readonly = self.extract_meta_info(t, node) + types.append(unwrapped_type) + if is_required is True or (is_required is None and total): + required_keys.add(field) + if is_readonly: + readonly_keys.add(field) # Perform various validations after unwrapping. for t in types: diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 622004758364b..56bf2377c8dc1 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -4011,6 +4011,43 @@ x["other"] = "a" # E: ReadOnly TypedDict key "other" TypedDict is mutated [typ [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testTypedDictFunctionalSyntaxWithMultipleSpecialForms] +from typing import ReadOnly, Required, NotRequired, TypedDict +from typing_extensions import Annotated +D1 = TypedDict("D1", {"x": int, "y": ReadOnly[Required[int]]}, total=False) +D2 = TypedDict("D2", {"x": int, "y": Required[ReadOnly[int]]}, total=False) +D3 = TypedDict("D3", {"x": int, "y": ReadOnly[NotRequired[int]]}) +D4 = TypedDict("D4", {"x": int, "y": NotRequired[ReadOnly[int]]}) +D5 = TypedDict("D5", {"x": int, "y": Annotated[ReadOnly[int], "some annotation text"]}) +D6 = TypedDict("D6", {"x": int, "y": ReadOnly[Annotated[int, "some annotation text"]]}) +D7 = TypedDict("D7", {"x": int, "y": Annotated[ReadOnly[NotRequired[int]], "some annotation text"]}) +D8 = TypedDict("D8", {"x": int, "y": NotRequired[ReadOnly[Annotated[int, "some annotation text"]]]}) +d1: D1 +d2: D2 +d3: D3 +d4: D4 +d5: D5 +d6: D6 +d7: D7 +d8: D8 +reveal_type(d1) # N: Revealed type is "TypedDict('__main__.D1', {'x'?: builtins.int, 'y'=: builtins.int})" +reveal_type(d2) # N: Revealed type is "TypedDict('__main__.D2', {'x'?: builtins.int, 'y'=: builtins.int})" +reveal_type(d3) # N: Revealed type is "TypedDict('__main__.D3', {'x': builtins.int, 'y'?=: builtins.int})" +reveal_type(d4) # N: Revealed type is "TypedDict('__main__.D4', {'x': builtins.int, 'y'?=: builtins.int})" +reveal_type(d5) # N: Revealed type is "TypedDict('__main__.D5', {'x': builtins.int, 'y'=: builtins.int})" +reveal_type(d6) # N: Revealed type is "TypedDict('__main__.D6', {'x': builtins.int, 'y'=: builtins.int})" +reveal_type(d7) # N: Revealed type is "TypedDict('__main__.D7', {'x': builtins.int, 'y'?=: builtins.int})" +reveal_type(d8) # N: Revealed type is "TypedDict('__main__.D8', {'x': builtins.int, 'y'?=: builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictFunctionalSyntaxWithContradictorySpecialForms] +from typing import Required, NotRequired, TypedDict +D1 = TypedDict("D1", {"x": NotRequired[Required[int]]}) # E: "Required[]" type cannot be nested +D2 = TypedDict("D2", {"x": Required[NotRequired[int]]}) # E: "NotRequired[]" type cannot be nested +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testTypedDictReadOnlyCreation] from typing import ReadOnly, TypedDict From c06fef7adc222cfe3450a794c3b23e11f5a5cb28 Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Wed, 22 Apr 2026 12:05:26 +0100 Subject: [PATCH 02/24] Subtypes fix: required keys must remain required Fix a bug where a non-required key was considered consistent with a required key if the latter was marked as read-only. --- mypy/subtypes.py | 39 ++++++++++++++--------------- test-data/unit/check-typeddict.test | 14 +++++++++++ 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index b8e8d5e3b79df..828dcfd195b76 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -923,6 +923,25 @@ def visit_typeddict_type(self, left: TypedDictType) -> bool: return True # Fast path if not left.names_are_wider_than(right): return False + + # Perform fast key-based checks before recursing into value types + for name in right.items: + l_required = name in left.required_keys + r_required = name in right.required_keys + l_mutable = name not in left.readonly_keys + r_mutable = name not in right.readonly_keys + + # Required keys must remain required + if r_required and not l_required: + return False + # Mutable keys must remain mutable + if r_mutable and not l_mutable: + return False + # Mutable optional keys must also remain optional, + # to retain the ability to delete them + if r_mutable and not r_required and l_required: + return False + for name, l, r in left.zip(right): # TODO: should we pass on the full subtype_context here and below? right_readonly = name in right.readonly_keys @@ -941,26 +960,6 @@ def visit_typeddict_type(self, left: TypedDictType) -> bool: check = self._is_subtype(l, r) if not check: return False - # Non-required key is not compatible with a required key since - # indexing may fail unexpectedly if a required key is missing. - # Required key is not compatible with a non-read-only non-required - # key since the prior doesn't support 'del' but the latter should - # support it. - # Required key is compatible with a read-only non-required key. - required_differ = (name in left.required_keys) != (name in right.required_keys) - if not right_readonly and required_differ: - return False - # Readonly fields check: - # - # A = TypedDict('A', {'x': ReadOnly[int]}) - # B = TypedDict('B', {'x': int}) - # def reset_x(b: B) -> None: - # b['x'] = 0 - # - # So, `A` cannot be a subtype of `B`, while `B` can be a subtype of `A`, - # because you can use `B` everywhere you use `A`, but not the other way around. - if name in left.readonly_keys and name not in right.readonly_keys: - return False # (NOTE: Fallbacks don't matter.) return True else: diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 56bf2377c8dc1..f7cbbd19e0919 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -4278,6 +4278,20 @@ def f(b: B): [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testTypedDictNotRequiredInconsistentWithRequiredReadOnly] +from typing import NotRequired, ReadOnly, Required, TypedDict + +class A(TypedDict): + x: Required[ReadOnly[str]] + +class B(TypedDict): + x: NotRequired[str] + +def f(b: B): + a: A = b # E: Incompatible types in assignment (expression has type "B", variable has type "A") +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testTypedDictReadOnlyCall] from typing import ReadOnly, TypedDict From 4a82d3ce1a7730d38c3122e8f06d8c1611a4ab3a Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Wed, 22 Apr 2026 12:45:15 +0100 Subject: [PATCH 03/24] Meet fix: mutable keys must remain mutable Fix a bug where the meet of a mutable and readonly key was incorrectly set as readonly. This bug was caused by mistakenly unioning the readonly key sets, which happened to work for the tested case where readonly keys never appeared in the other type. --- mypy/meet.py | 7 ++++++- test-data/unit/check-typeddict.test | 13 ++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/mypy/meet.py b/mypy/meet.py index cb8ad75f6013d..36d6f47b18a10 100644 --- a/mypy/meet.py +++ b/mypy/meet.py @@ -1108,7 +1108,13 @@ def visit_typeddict_type(self, t: TypedDictType) -> ProperType: ): return self.default(self.s) item_list: list[tuple[str, Type]] = [] + readonly_keys: set[str] = set() for item_name, s_item_type, t_item_type in self.s.zipall(t): + # Missing keys are implicitly ReadOnly[NotRequired[object]] + s_readonly = item_name in self.s.readonly_keys or s_item_type is None + t_readonly = item_name in t.readonly_keys or t_item_type is None + if s_readonly and t_readonly: + readonly_keys.add(item_name) if s_item_type is not None: item_list.append((item_name, s_item_type)) else: @@ -1118,7 +1124,6 @@ def visit_typeddict_type(self, t: TypedDictType) -> ProperType: items = dict(item_list) fallback = self.s.create_anonymous_fallback() required_keys = t.required_keys | self.s.required_keys - readonly_keys = t.readonly_keys | self.s.readonly_keys return TypedDictType(items, required_keys, readonly_keys, fallback) elif isinstance(self.s, Instance) and is_subtype(t, self.s): return t diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index f7cbbd19e0919..52ec0aa7f0f87 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -4339,7 +4339,7 @@ x["two"] = "a" # E: ReadOnly TypedDict key "two" TypedDict is mutated [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] -[case testMeetOfTypedDictsWithReadOnly] +[case testMeetOfTypedDictsWithNonOverlappingReadOnlyKeys] from typing import TypeVar, Callable, TypedDict, ReadOnly XY = TypedDict('XY', {'x': ReadOnly[int], 'y': int}) YZ = TypedDict('YZ', {'y': int, 'z': ReadOnly[int]}) @@ -4350,6 +4350,17 @@ reveal_type(f(g)) # N: Revealed type is "TypedDict({'x'=: builtins.int, 'y': bu [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testMeetOfTypedDictsWithOverlappingReadOnlyAndMutableKeys] +from typing import TypedDict, TypeVar, Callable, ReadOnly +XY = TypedDict('XY', {'x': int, 'y': int}) +YZ = TypedDict('YZ', {'y': ReadOnly[int], 'z': int}) +T = TypeVar('T') +def f(x: Callable[[T, T], None]) -> T: pass +def g(x: XY, y: YZ) -> None: pass +reveal_type(f(g)) # N: Revealed type is "TypedDict({'x': builtins.int, 'y': builtins.int, 'z': builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testTypedDictReadOnlyUnpack] from typing import TypedDict from typing_extensions import Unpack, ReadOnly From ca099f4b5af9fc94574aaddbe3166dab2ff81079 Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Wed, 22 Apr 2026 12:26:05 +0100 Subject: [PATCH 04/24] Improve TypedDict meet The TypedDict meet is returning Never whenever inputs have mismatched keys (value types or requiredness). However: * when a key is readonly, the meet value type can be a subtype of it * when a key is readonly and not required, the meet key can be required * when both keys are readonly and not required, the meet value type can be uninhabited (absent) --- mypy/meet.py | 88 +++++++++++++++++----- test-data/unit/check-typeddict.test | 113 ++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 20 deletions(-) diff --git a/mypy/meet.py b/mypy/meet.py index 36d6f47b18a10..ac184d21ddbd7 100644 --- a/mypy/meet.py +++ b/mypy/meet.py @@ -1100,30 +1100,78 @@ def visit_tuple_type(self, t: TupleType) -> ProperType: return t return self.default(self.s) + def resolve_typeddict_item_type( + self, + name: str, + s: TypedDictType, + s_item_type: Type | None, + t: TypedDictType, + t_item_type: Type | None, + ) -> tuple[Type | None, bool]: + """Return the type and readonlyness of a meet item. + + If the parent constraints are mutually incompatible, the + returned type will be None; the overall meet type should + be UninhabitedType. + """ + # A missing key is implicitly ReadOnly[NotRequired[object]] + l_mutable = s_item_type is not None and name not in s.readonly_keys + r_mutable = t_item_type is not None and name not in t.readonly_keys + l_required = name in s.required_keys + r_required = name in t.required_keys + + is_readonly = not l_mutable and not r_mutable + + if t_item_type is None: + assert s_item_type is not None + meet_type = s_item_type + elif s_item_type is None: + meet_type = t_item_type + elif l_mutable and r_mutable: + if is_equivalent(s_item_type, t_item_type) and r_required == l_required: + meet_type = s_item_type + else: + meet_type = None + elif l_mutable: + if is_subtype(s_item_type, t_item_type) and not (r_required and not l_required): + meet_type = s_item_type + else: + meet_type = None + elif r_mutable: + if is_subtype(t_item_type, s_item_type) and not (l_required and not r_required): + meet_type = t_item_type + else: + meet_type = None + else: + m = meet_types(s_item_type, t_item_type) + if not isinstance(m, UninhabitedType): + meet_type = m + elif not l_required and not r_required: + # A non-required key can be Never + meet_type = m + else: + meet_type = None + + return (meet_type, is_readonly) + def visit_typeddict_type(self, t: TypedDictType) -> ProperType: if isinstance(self.s, TypedDictType): - for name, l, r in self.s.zip(t): - if not is_equivalent(l, r) or (name in t.required_keys) != ( - name in self.s.required_keys - ): - return self.default(self.s) - item_list: list[tuple[str, Type]] = [] + items: dict[str, Type] = {} readonly_keys: set[str] = set() - for item_name, s_item_type, t_item_type in self.s.zipall(t): - # Missing keys are implicitly ReadOnly[NotRequired[object]] - s_readonly = item_name in self.s.readonly_keys or s_item_type is None - t_readonly = item_name in t.readonly_keys or t_item_type is None - if s_readonly and t_readonly: - readonly_keys.add(item_name) - if s_item_type is not None: - item_list.append((item_name, s_item_type)) - else: - # at least one of s_item_type and t_item_type is not None - assert t_item_type is not None - item_list.append((item_name, t_item_type)) - items = dict(item_list) + for name, s_item_type, t_item_type in self.s.zipall(t): + meet_type, is_readonly = self.resolve_typeddict_item_type( + name, self.s, s_item_type, t, t_item_type + ) + + if meet_type is None: + return self.default(self.s) + + items[name] = meet_type + if is_readonly: + readonly_keys.add(name) + fallback = self.s.create_anonymous_fallback() - required_keys = t.required_keys | self.s.required_keys + required_keys = self.s.required_keys | t.required_keys return TypedDictType(items, required_keys, readonly_keys, fallback) elif isinstance(self.s, Instance) and is_subtype(t, self.s): return t diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 52ec0aa7f0f87..8bcf5c769dd57 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -4361,6 +4361,119 @@ reveal_type(f(g)) # N: Revealed type is "TypedDict({'x': builtins.int, 'y': bui [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testMeetOfTypedDictsWithIncompatibleReadOnlyCommonKeysIsUninhabitedUnlessNotRequired] +from typing import TypedDict, TypeVar, Callable, ReadOnly +A = TypedDict('A', {'x': ReadOnly[int]}) +B = TypedDict('B', {'x': ReadOnly[str]}) +C = TypedDict('C', {'x': ReadOnly[int]}, total=False) +D = TypedDict('D', {'x': ReadOnly[str]}, total=False) +T = TypeVar('T') +def meet(x: Callable[[T, T], None]) -> T: pass +def fAB(x: A, y: B) -> None: pass +def fAD(x: A, y: D) -> None: pass +def fCB(x: C, y: B) -> None: pass +def fCD(x: C, y: D) -> None: pass +if int(): + reveal_type(meet(fAB)) # N: Revealed type is "Never" +if int(): + reveal_type(meet(fAD)) # N: Revealed type is "Never" +if int(): + reveal_type(meet(fCB)) # N: Revealed type is "Never" +reveal_type(meet(fCD)) # N: Revealed type is "TypedDict({'x'?=: Never})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testMeetOfTypedDictsWithCompatibleReadOnlyAndMutableCommonKeys] +from typing import TypedDict, TypeVar, Callable, NotRequired, ReadOnly, Union +A = TypedDict('A', {'x': int, 'y': ReadOnly[Union[int, str]]}) +B = TypedDict('B', {'x': ReadOnly[float], 'y': str}) +C = TypedDict('C', {'x': ReadOnly[NotRequired[float]], 'y': str}) +D = TypedDict('D', {'x': NotRequired[int], 'y': ReadOnly[Union[int, str]]}) +T = TypeVar('T') +def meet(x: Callable[[T, T], None]) -> T: pass +def fAB(x: A, y: B) -> None: pass +def fBA(x: B, y: A) -> None: pass +def fAC(x: A, y: C) -> None: pass +def fCA(x: C, y: A) -> None: pass +def fCD(x: C, y: D) -> None: pass +def fDC(x: D, y: C) -> None: pass +reveal_type(meet(fAB)) # N: Revealed type is "TypedDict({'x': builtins.int, 'y': builtins.str})" +reveal_type(meet(fBA)) # N: Revealed type is "TypedDict({'x': builtins.int, 'y': builtins.str})" +reveal_type(meet(fAC)) # N: Revealed type is "TypedDict({'x': builtins.int, 'y': builtins.str})" +reveal_type(meet(fCA)) # N: Revealed type is "TypedDict({'x': builtins.int, 'y': builtins.str})" +reveal_type(meet(fCD)) # N: Revealed type is "TypedDict({'x'?: builtins.int, 'y': builtins.str})" +reveal_type(meet(fDC)) # N: Revealed type is "TypedDict({'x'?: builtins.int, 'y': builtins.str})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testMeetOfTypedDictsWithIncompatibleReadOnlyAndMutableCommonKeysIsUninhabited] +from typing import TypedDict, TypeVar, Callable, ReadOnly +XYa = TypedDict('XYa', {'x': int, 'y': ReadOnly[int]}) +YbZ = TypedDict('YbZ', {'y': str, 'z': int}) +T = TypeVar('T') +def f(x: Callable[[T, T], None]) -> T: pass +def g(x: XYa, y: YbZ) -> None: pass +reveal_type(f(g)) # N: Revealed type is "Never" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testMeetOfTypedDictsWithCompatibleReadOnlyCommonKeys] +from typing import TypedDict, TypeVar, Callable, ReadOnly +A = TypedDict('A', {'a': int}) +B = TypedDict('B', {'b': int}) +Xa = TypedDict('Xa', {'x': ReadOnly[A]}) +Xb = TypedDict('Xb', {'x': ReadOnly[B]}) +T = TypeVar('T') +def f(x: Callable[[T, T], None]) -> T: pass +def g(x: Xa, y: Xb) -> None: pass +reveal_type(f(g)) # N: Revealed type is "TypedDict({'x'=: TypedDict({'a': builtins.int, 'b': builtins.int})})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testMeetOfReadOnlyTypedDictsWithNoCommonKeysHasAllKeysAndNewFallback] +from typing import TypedDict, TypeVar, Callable, ReadOnly +X = TypedDict('X', {'x': ReadOnly[int]}) +Z = TypedDict('Z', {'z': ReadOnly[int]}) +T = TypeVar('T') +def f(x: Callable[[T, T], None]) -> T: pass +def g(x: X, y: Z) -> None: pass +reveal_type(f(g)) # N: Revealed type is "TypedDict({'x'=: builtins.int, 'z'=: builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testMeetOfReadOnlyTypedDictsWithNonTotal] +from typing import TypedDict, TypeVar, Callable, ReadOnly +XY = TypedDict('XY', {'x': ReadOnly[int], 'y': ReadOnly[int]}, total=False) +YZ = TypedDict('YZ', {'y': ReadOnly[int], 'z': ReadOnly[int]}, total=False) +T = TypeVar('T') +def f(x: Callable[[T, T], None]) -> T: pass +def g(x: XY, y: YZ) -> None: pass +reveal_type(f(g)) # N: Revealed type is "TypedDict({'x'?=: builtins.int, 'y'?=: builtins.int, 'z'?=: builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testMeetOfReadOnlyTypedDictsWithNonOverlappingNonTotalAndTotal] +from typing import TypedDict, TypeVar, Callable, ReadOnly +XY = TypedDict('XY', {'x': ReadOnly[int]}, total=False) +YZ = TypedDict('YZ', {'y': ReadOnly[int], 'z': ReadOnly[int]}) +T = TypeVar('T') +def f(x: Callable[[T, T], None]) -> T: pass +def g(x: XY, y: YZ) -> None: pass +reveal_type(f(g)) # N: Revealed type is "TypedDict({'x'?=: builtins.int, 'y'=: builtins.int, 'z'=: builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testMeetOfReadOnlyTypedDictsWithOverlappingNonTotalAndTotal] +from typing import TypedDict, TypeVar, Callable, ReadOnly +XY = TypedDict('XY', {'x': ReadOnly[int], 'y': ReadOnly[int]}, total=False) +YZ = TypedDict('YZ', {'y': ReadOnly[int], 'z': ReadOnly[int]}) +T = TypeVar('T') +def f(x: Callable[[T, T], None]) -> T: pass +def g(x: XY, y: YZ) -> None: pass +reveal_type(f(g)) # N: Revealed type is "TypedDict({'x'?=: builtins.int, 'y'=: builtins.int, 'z'=: builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testTypedDictReadOnlyUnpack] from typing import TypedDict from typing_extensions import Unpack, ReadOnly From f79ba70a1e477eba575c937c947bb655d97f082f Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Wed, 22 Apr 2026 13:23:02 +0100 Subject: [PATCH 05/24] Join fix: readonly keys must remain readonly A typo was causing the readonly state of keys on the LHS of a TypedDict join to be ignored. --- mypy/join.py | 2 +- test-data/unit/check-typeddict.test | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/mypy/join.py b/mypy/join.py index a8c9910e60bb7..4a004af8f0de6 100644 --- a/mypy/join.py +++ b/mypy/join.py @@ -626,7 +626,7 @@ def visit_typeddict_type(self, t: TypedDictType) -> ProperType: # self.s might be missing from the join if the types are incompatible. required_keys = all_keys & t.required_keys & self.s.required_keys # If one type has a key as readonly, we mark it as readonly for both: - readonly_keys = (t.readonly_keys | t.readonly_keys) & all_keys + readonly_keys = (self.s.readonly_keys | t.readonly_keys) & all_keys return TypedDictType(items, required_keys, readonly_keys, fallback) elif isinstance(self.s, Instance): return join_types(self.s, t.fallback) diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 8bcf5c769dd57..301fe3696cbc0 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -4563,6 +4563,19 @@ c: C = d # E: Incompatible types in assignment (expression has type "D", variab [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testTypedDictJoinWithReadOnly] +from typing import ReadOnly, TypedDict, TypeVar +A = TypedDict('A', {'x': ReadOnly[int]}) +B = TypedDict('B', {'x': int}) +T = TypeVar('T') +def j(x: T, y: T) -> T: return x +a: A +b: B +reveal_type(j(a, b)) # N: Revealed type is "TypedDict({'x'=: builtins.int})" +reveal_type(j(b, a)) # N: Revealed type is "TypedDict({'x'=: builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testTypedDictFinalAndClassVar] from typing import TypedDict, Final, ClassVar From 2ed799242690dd8e3c402eac75650a7f0e6af261 Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Wed, 22 Apr 2026 13:37:51 +0100 Subject: [PATCH 06/24] Improve TypedDict join Improve TypedDict joins where keys mismatch by returning a readonly key with the joined type instead of discarding it. --- mypy/join.py | 39 +++++++++------ test-data/unit/check-recursive-types.test | 2 +- test-data/unit/check-typeddict.test | 60 ++++++++++++++++------- 3 files changed, 67 insertions(+), 34 deletions(-) diff --git a/mypy/join.py b/mypy/join.py index 4a004af8f0de6..aca3dd3b097a3 100644 --- a/mypy/join.py +++ b/mypy/join.py @@ -612,21 +612,32 @@ def visit_tuple_type(self, t: TupleType) -> ProperType: def visit_typeddict_type(self, t: TypedDictType) -> ProperType: if isinstance(self.s, TypedDictType): - items = { - item_name: s_item_type - for (item_name, s_item_type, t_item_type) in self.s.zip(t) - if ( - is_equivalent(s_item_type, t_item_type) - and (item_name in t.required_keys) == (item_name in self.s.required_keys) - ) - } + items = {} + readonly_keys = set() + for item_name, s_item_type, t_item_type in self.s.zip(t): + if is_equivalent(s_item_type, t_item_type): + items[item_name] = s_item_type + s_required = item_name in self.s.required_keys + t_required = item_name in t.required_keys + s_readonly = item_name in self.s.readonly_keys + t_readonly = item_name in t.readonly_keys + if s_readonly or t_readonly: + # If either type has no setitem overload for this key, + # then the join supertype must also omit it + readonly_keys.add(item_name) + elif s_required != t_required: + # As one of the input types marks the key as not required, + # it must be not required in the join supertype. The other + # input type does not have a delitem overload for the key, + # so it must be omitted in the join supertype too, by marking + # the key as readonly. + readonly_keys.add(item_name) + else: + items[item_name] = join_types(s_item_type, t_item_type) + readonly_keys.add(item_name) + fallback = self.s.create_anonymous_fallback() - all_keys = set(items.keys()) - # We need to filter by items.keys() since some required keys present in both t and - # self.s might be missing from the join if the types are incompatible. - required_keys = all_keys & t.required_keys & self.s.required_keys - # If one type has a key as readonly, we mark it as readonly for both: - readonly_keys = (self.s.readonly_keys | t.readonly_keys) & all_keys + required_keys = t.required_keys & self.s.required_keys return TypedDictType(items, required_keys, readonly_keys, fallback) elif isinstance(self.s, Instance): return join_types(self.s, t.fallback) diff --git a/test-data/unit/check-recursive-types.test b/test-data/unit/check-recursive-types.test index 10333d113a689..168520bf3197d 100644 --- a/test-data/unit/check-recursive-types.test +++ b/test-data/unit/check-recursive-types.test @@ -749,7 +749,7 @@ T = TypeVar("T") def f(x: T, y: T) -> T: ... # Join for recursive types is very basic, but just add tests that we don't crash. reveal_type(f(tda1, tda2)) # N: Revealed type is "TypedDict({'x': builtins.int, 'y': TypedDict('__main__.TDA1', {'x': builtins.int, 'y': ...})})" -reveal_type(f(tda1, tdb)) # N: Revealed type is "TypedDict({})" +reveal_type(f(tda1, tdb)) # N: Revealed type is "TypedDict({'x'=: builtins.object, 'y'=: builtins.object})" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 301fe3696cbc0..24178c24dee48 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -472,7 +472,7 @@ def f(a: A) -> None: pass l = [a, b] # Join generates an anonymous TypedDict f(l) # E: Argument 1 to "f" has incompatible type "list[TypedDict({'x': int})]"; expected "A" ll = [b, c] -f(ll) # E: Argument 1 to "f" has incompatible type "list[TypedDict({'x': int, 'z': str})]"; expected "A" +f(ll) # E: Argument 1 to "f" has incompatible type "list[TypedDict({'x': int, 'z': str, 'a'=: object})]"; expected "A" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] @@ -540,16 +540,28 @@ reveal_type(joined_points) # N: Revealed type is "TypedDict({'x': builtins.int, [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] -[case testJoinOfTypedDictRemovesNonequivalentKeys] -from typing import TypedDict +[case testJoinOfTypedDictJoinsNonequivalentKeys] +from typing import TypedDict, NotRequired CellWithInt = TypedDict('CellWithInt', {'value': object, 'meta': int}) -CellWithObject = TypedDict('CellWithObject', {'value': object, 'meta': object}) +CellWithString = TypedDict('CellWithString', {'value': object, 'meta': str}) +CellWithNonRequiredInt = TypedDict('CellWithNonRequiredInt', {'value': object, 'meta': NotRequired[int]}) +CellWithNonRequiredString = TypedDict('CellWithNonRequiredString', {'value': object, 'meta': NotRequired[str]}) c1 = CellWithInt(value=1, meta=42) -c2 = CellWithObject(value=2, meta='turtle doves') -joined_cells = [c1, c2] -reveal_type(c1) # N: Revealed type is "TypedDict('__main__.CellWithInt', {'value': builtins.object, 'meta': builtins.int})" -reveal_type(c2) # N: Revealed type is "TypedDict('__main__.CellWithObject', {'value': builtins.object, 'meta': builtins.object})" -reveal_type(joined_cells) # N: Revealed type is "builtins.list[TypedDict({'value': builtins.object})]" +c2 = CellWithString(value=2, meta='turtle doves') +c3 = CellWithNonRequiredInt(value=1) +c4 = CellWithNonRequiredString(value=2) +j12 = [c1, c2] +j14 = [c1, c4] +j32 = [c3, c2] +j34 = [c3, c4] +reveal_type(c1) # N: Revealed type is "TypedDict('__main__.CellWithInt', {'value': builtins.object, 'meta': builtins.int})" +reveal_type(c2) # N: Revealed type is "TypedDict('__main__.CellWithString', {'value': builtins.object, 'meta': builtins.str})" +reveal_type(c3) # N: Revealed type is "TypedDict('__main__.CellWithNonRequiredInt', {'value': builtins.object, 'meta'?: builtins.int})" +reveal_type(c4) # N: Revealed type is "TypedDict('__main__.CellWithNonRequiredString', {'value': builtins.object, 'meta'?: builtins.str})" +reveal_type(j12) # N: Revealed type is "builtins.list[TypedDict({'value': builtins.object, 'meta'=: builtins.object})]" +reveal_type(j14) # N: Revealed type is "builtins.list[TypedDict({'value': builtins.object, 'meta'?=: builtins.object})]" +reveal_type(j32) # N: Revealed type is "builtins.list[TypedDict({'value': builtins.object, 'meta'?=: builtins.object})]" +reveal_type(j34) # N: Revealed type is "builtins.list[TypedDict({'value': builtins.object, 'meta'?=: builtins.object})]" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] @@ -751,8 +763,8 @@ T = TypeVar('T') def join(x: T, y: T) -> T: return x ab = join(A(x='', y=1, z=''), B(x='', z=1)) ac = join(A(x='', y=1, z=''), C(x='', y=0, z=1)) -ab['y'] # E: "y" is not a valid TypedDict key; expected one of ("x") -ac['a'] # E: "a" is not a valid TypedDict key; expected one of ("x", "y") +ab['y'] # E: "y" is not a valid TypedDict key; expected one of ("x", "z") +ac['a'] # E: "a" is not a valid TypedDict key; expected one of ("x", "y", "z") [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] @@ -973,10 +985,10 @@ p4: Point = {'x': 1, 'y': 2} [case testCannotCreateAnonymousTypedDictInstanceUsingDictLiteralWithExtraItems] from typing import TypedDict, TypeVar A = TypedDict('A', {'x': int, 'y': int}) -B = TypedDict('B', {'x': int, 'y': str}) +B = TypedDict('B', {'x': int}) T = TypeVar('T') def join(x: T, y: T) -> T: return x -ab = join(A(x=1, y=1), B(x=1, y='')) +ab = join(A(x=1, y=1), B(x=1)) if int(): ab = {'x': 1, 'z': 1} # E: Expected TypedDict key "x" but found keys ("x", "z") [builtins fixtures/dict.pyi] @@ -990,7 +1002,7 @@ T = TypeVar('T') def join(x: T, y: T) -> T: return x ab = join(A(x=1, y=1, z=1), B(x=1, y=1, z='')) if int(): - ab = {} # E: Expected TypedDict keys ("x", "y") but found no keys + ab = {} # E: Expected TypedDict keys ("x", "y", "z") but found no keys [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] @@ -1411,7 +1423,7 @@ a: A b: B c: C reveal_type(j(a, b)) \ - # N: Revealed type is "TypedDict({})" + # N: Revealed type is "TypedDict({'x'?=: builtins.int})" reveal_type(j(b, b)) \ # N: Revealed type is "TypedDict({'x'?: builtins.int})" reveal_type(j(c, c)) \ @@ -1473,7 +1485,7 @@ def f(a: A) -> None: pass l = [a, b] # Join generates an anonymous TypedDict f(l) # E: Argument 1 to "f" has incompatible type "list[TypedDict({'x'?: int})]"; expected "A" ll = [b, c] -f(ll) # E: Argument 1 to "f" has incompatible type "list[TypedDict({'x'?: int, 'z'?: str})]"; expected "A" +f(ll) # E: Argument 1 to "f" has incompatible type "list[TypedDict({'x'?: int, 'z'?: str, 'a'?=: object})]"; expected "A" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] @@ -4566,13 +4578,23 @@ c: C = d # E: Incompatible types in assignment (expression has type "D", variab [case testTypedDictJoinWithReadOnly] from typing import ReadOnly, TypedDict, TypeVar A = TypedDict('A', {'x': ReadOnly[int]}) -B = TypedDict('B', {'x': int}) +B = TypedDict('B', {'x': ReadOnly[int]}, total=False) +C = TypedDict('C', {'x': int}) +D = TypedDict('D', {'x': int}, total=False) T = TypeVar('T') def j(x: T, y: T) -> T: return x a: A b: B -reveal_type(j(a, b)) # N: Revealed type is "TypedDict({'x'=: builtins.int})" -reveal_type(j(b, a)) # N: Revealed type is "TypedDict({'x'=: builtins.int})" +c: C +d: D +reveal_type(j(a, b)) # N: Revealed type is "TypedDict({'x'?=: builtins.int})" +reveal_type(j(b, a)) # N: Revealed type is "TypedDict({'x'?=: builtins.int})" +reveal_type(j(a, c)) # N: Revealed type is "TypedDict({'x'=: builtins.int})" +reveal_type(j(c, a)) # N: Revealed type is "TypedDict({'x'=: builtins.int})" +reveal_type(j(a, d)) # N: Revealed type is "TypedDict({'x'?=: builtins.int})" +reveal_type(j(d, a)) # N: Revealed type is "TypedDict({'x'?=: builtins.int})" +reveal_type(j(b, c)) # N: Revealed type is "TypedDict({'x'?=: builtins.int})" +reveal_type(j(c, b)) # N: Revealed type is "TypedDict({'x'?=: builtins.int})" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] From ab1a151f318a402264a0fdb3964c570ac7161f00 Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Fri, 1 May 2026 11:46:49 +0100 Subject: [PATCH 07/24] Expand TypedDict subclassing tests Prior to implementing support for subclass refinement, expand the test suite with all the cases that need to be tested. --- test-data/unit/check-typeddict.test | 279 ++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 24178c24dee48..4f4eb550ef04f 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -320,6 +320,75 @@ reveal_type(p2) # N: Revealed type is "TypedDict('__main__.Point2', {'x': builti [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testCanCreateTypedDictWithClassOverwriting3] +from typing import TypedDict + +class Point1(TypedDict): + x: float +class Point2(Point1): + x: int # E: Overwriting TypedDict field "x" while extending + +p2: Point2 +reveal_type(p2) # N: Revealed type is "TypedDict('__main__.Point2', {'x': builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testCanCreateTypedDictWithClassOverwriting4] +from typing import TypedDict + +class Point1(TypedDict): + x: int +class Point2(TypedDict, total=False): + x: int +class Bad(Point1, Point2): # E: Overwriting TypedDict field "x" while merging + pass + +b: Bad +reveal_type(b) # N: Revealed type is "TypedDict('__main__.Bad', {'x': builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testCanCreateTypedDictWithClassOverwriting5] +from typing import TypedDict + +class Point1(TypedDict): + x: int +class Point2(Point1, total=False): + x: int # E: Overwriting TypedDict field "x" while extending + +p2: Point2 +reveal_type(p2) # N: Revealed type is "TypedDict('__main__.Point2', {'x': builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testCanCreateTypedDictWithClassOverwriting6] +from typing import TypedDict + +class Point1(TypedDict, total=False): + x: int +class Point2(TypedDict): + x: int +class Bad(Point1, Point2): # E: Overwriting TypedDict field "x" while merging + pass + +b: Bad +reveal_type(b) # N: Revealed type is "TypedDict('__main__.Bad', {'x': builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testCanCreateTypedDictWithClassOverwriting7] +from typing import TypedDict + +class Point1(TypedDict, total=False): + x: int +class Point2(Point1): + x: int # E: Overwriting TypedDict field "x" while extending + +p2: Point2 +reveal_type(p2) # N: Revealed type is "TypedDict('__main__.Point2', {'x': builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + -- Subtyping @@ -1739,6 +1808,26 @@ reveal_type(x['a']['b']) # N: Revealed type is "builtins.int" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testForwardReferenceInOverwrittenKey] +from typing import ReadOnly, TypedDict + +class X("XB"): pass +class Y("YB"): pass +class Z("ZB"): pass + +class A(TypedDict): + key: "X" +class B(A): + key: "Y" # E: Overwriting TypedDict field "key" while extending +class C(A): + key: "Z" # E: Overwriting TypedDict field "key" while extending + +class XB: pass +class YB(X): pass +class ZB: pass +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testSelfRecursiveTypedDictInheriting] from typing import TypedDict @@ -4276,6 +4365,196 @@ accepts_B(b) [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testTypedDictReadOnlySubclassing] +from typing import ReadOnly, TypedDict + +class A(TypedDict): + key: ReadOnly[str] + +class B(A): + key: str # E: Overwriting TypedDict field "key" while extending + +a: A +b: B + +def accepts_A(d: A): ... +def accepts_B(d: B): ... + +accepts_A(a) +accepts_A(b) +accepts_B(a) +accepts_B(b) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictReadOnlySubclassingFieldSubtype] +from typing import ReadOnly, TypedDict + +class A(TypedDict): + key: ReadOnly[float] +class B(A): + key: int # E: Overwriting TypedDict field "key" while extending +class C(A): + key: ReadOnly[int] # E: Overwriting TypedDict field "key" while extending + +a: A +reveal_type(a) # N: Revealed type is "TypedDict('__main__.A', {'key'=: builtins.float})" +b: B +reveal_type(b) # N: Revealed type is "TypedDict('__main__.B', {'key'=: builtins.int})" +c: C +reveal_type(c) # N: Revealed type is "TypedDict('__main__.C', {'key'=: builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictReadOnlySubclassingFieldSubtypeWithForwardReferenceBases] +from typing import ReadOnly, TypedDict + +class X("XB"): pass +class Y("YB"): pass +class Z("ZB"): pass + +class A(TypedDict): + key: ReadOnly["X"] +class B(A): + key: "Y" # E: Overwriting TypedDict field "key" while extending +class C(A): + key: "Z" # E: Overwriting TypedDict field "key" while extending + +class XB: pass +class YB(X): pass +class ZB: pass +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictReadOnlyIllegalSubclassingFieldSupertype] +from typing import ReadOnly, TypedDict + +class A(TypedDict): + key: ReadOnly[int] +class B(A): + key: float # E: Overwriting TypedDict field "key" while extending +class C(A): + key: ReadOnly[float] # E: Overwriting TypedDict field "key" while extending + +a: A +reveal_type(a) # N: Revealed type is "TypedDict('__main__.A', {'key'=: builtins.int})" +b: B +reveal_type(b) # N: Revealed type is "TypedDict('__main__.B', {'key'=: builtins.float})" +c: C +reveal_type(c) # N: Revealed type is "TypedDict('__main__.C', {'key'=: builtins.float})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictReadOnlyMixinFieldSubtype] +from typing import ReadOnly, TypedDict + +class A(TypedDict): + key: ReadOnly[float] + +class B(TypedDict): + key: ReadOnly[int] + +class C(B, A): # E: Overwriting TypedDict field "key" while merging + pass + +class D(A, B): # E: Overwriting TypedDict field "key" while merging + pass + +c: C +reveal_type(c) # N: Revealed type is "TypedDict('__main__.C', {'key'=: builtins.int})" +d: D +reveal_type(d) # N: Revealed type is "TypedDict('__main__.D', {'key'=: builtins.float})" + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictReadOnlySubclassingTotality] +from typing import ReadOnly, TypedDict + +class A(TypedDict, total=False): + key: ReadOnly[int] + +class B(A): + key: int # E: Overwriting TypedDict field "key" while extending + +a: A +reveal_type(a) # N: Revealed type is "TypedDict('__main__.A', {'key'?=: builtins.int})" +b: B +reveal_type(b) # N: Revealed type is "TypedDict('__main__.B', {'key'=: builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictReadOnlyIllegalSubclassingTotality] +from typing import ReadOnly, TypedDict + +class A(TypedDict): + key: ReadOnly[int] + +class B(A, total=False): + key: int # E: Overwriting TypedDict field "key" while extending + +a: A +reveal_type(a) # N: Revealed type is "TypedDict('__main__.A', {'key'=: builtins.int})" +b: B +reveal_type(b) # N: Revealed type is "TypedDict('__main__.B', {'key'=: builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictReadOnlyMixinTotality] +from typing import ReadOnly, TypedDict + +class A(TypedDict, total=True): + key: ReadOnly[int] + +class B(TypedDict, total=False): + key: ReadOnly[int] + +class C(B, A): # E: Overwriting TypedDict field "key" while merging + pass +class D(A, B): # E: Overwriting TypedDict field "key" while merging + pass + +c: C +reveal_type(c) # N: Revealed type is "TypedDict('__main__.C', {'key'=: builtins.int})" +d: D +reveal_type(d) # N: Revealed type is "TypedDict('__main__.D', {'key'=: builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictReadOnlyMixinWithMutableField] +from typing import ReadOnly, TypedDict + +class A(TypedDict): + key: ReadOnly[float] + +class B(TypedDict): + key: float + +class C(TypedDict): + key: int + +class D1(A, B): pass # E: Overwriting TypedDict field "key" while merging +d1: D1 +reveal_type(d1) # N: Revealed type is "TypedDict('__main__.D1', {'key'=: builtins.float})" +class D2(B, A): pass # E: Overwriting TypedDict field "key" while merging +d2: D2 +reveal_type(d2) # N: Revealed type is "TypedDict('__main__.D2', {'key'=: builtins.float})" +class D3(A, C): pass # E: Overwriting TypedDict field "key" while merging +d3: D3 +reveal_type(d3) # N: Revealed type is "TypedDict('__main__.D3', {'key'=: builtins.float})" +class D4(C, A): pass # E: Overwriting TypedDict field "key" while merging +d4: D4 +reveal_type(d4) # N: Revealed type is "TypedDict('__main__.D4', {'key'=: builtins.int})" +class D5(A, B, C): pass # E: Overwriting TypedDict field "key" while merging +d5: D5 +reveal_type(d5) # N: Revealed type is "TypedDict('__main__.D5', {'key'=: builtins.float})" +class D6(C, B, A): pass # E: Overwriting TypedDict field "key" while merging +d6: D6 +reveal_type(d6) # N: Revealed type is "TypedDict('__main__.D6', {'key'=: builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + + [case testTypedDictRequiredConsistentWithNotRequiredReadOnly] from typing import NotRequired, ReadOnly, Required, TypedDict From e626c19bacd9d8323d21522fb5ae2d945dc9e8c4 Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Fri, 1 May 2026 12:48:40 +0100 Subject: [PATCH 08/24] Support type refinement in TypedDict subclasses Allow readonly keys to be refined in subclasses, in line with PEP 705. For cases where refinement is not permitted by spec, provide a more detailed error message. As placeholders may now affect validation without ending up in the final TypedDictType, an `analysis_incomplete` field has been added, triggering reanalysis in subsequent rounds. This closes https://github.com/python/mypy/issues/7435 --- mypy/semanal.py | 2 +- mypy/semanal_typeddict.py | 260 ++++++++++++++++++++++------ mypy/types.py | 3 + test-data/unit/check-typeddict.test | 86 ++++----- 4 files changed, 251 insertions(+), 100 deletions(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index a958043fa35c2..97d6f27be9fbe 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -2108,7 +2108,7 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> bool: if ( defn.info and defn.info.typeddict_type - and not has_placeholder(defn.info.typeddict_type) + and not defn.info.typeddict_type.analysis_incomplete ): # This is a valid TypedDict, and it is fully analyzed. return True diff --git a/mypy/semanal_typeddict.py b/mypy/semanal_typeddict.py index 476e5169b68f6..8aac2a81d0eaf 100644 --- a/mypy/semanal_typeddict.py +++ b/mypy/semanal_typeddict.py @@ -2,14 +2,12 @@ from __future__ import annotations -from collections.abc import Collection -from typing import Final +from typing import Final, NamedTuple from mypy import errorcodes as codes, message_registry from mypy.errorcodes import ErrorCode from mypy.expandtype import expand_type from mypy.exprtotype import TypeTranslationError, expr_to_unanalyzed_type -from mypy.message_registry import TYPEDDICT_OVERRIDE_MERGE from mypy.messages import MessageBuilder from mypy.nodes import ( ARG_NAMED, @@ -41,6 +39,7 @@ require_bool_literal_argument, ) from mypy.state import state +from mypy.subtypes import is_equivalent, is_subtype from mypy.typeanal import check_for_explicit_any, has_any_from_unimported_type from mypy.types import ( TPDICT_NAMES, @@ -59,6 +58,14 @@ ) +class FieldSource(NamedTuple): + field_type: Type + is_readonly: bool + is_required: bool + base_name: str | None = None + child_field_ctx: AssignmentStmt | None = None + + class TypedDictAnalyzer: def __init__( self, options: Options, api: SemanticAnalyzerInterface, msg: MessageBuilder @@ -101,20 +108,20 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N if isinstance(defn.analyzed, TypedDictExpr): existing_info = defn.analyzed.info - field_types: dict[str, Type] | None if ( len(defn.base_type_exprs) == 1 and isinstance(defn.base_type_exprs[0], RefExpr) and defn.base_type_exprs[0].fullname in TPDICT_NAMES ): # Building a new TypedDict - field_types, statements, required_keys, readonly_keys = ( - self.analyze_typeddict_classdef_fields(defn) - ) - if field_types is None: + field_sources, statements = self.analyze_typeddict_classdef_fields(defn) + if field_sources is None: return True, None # Defer if self.api.is_func_scope() and "@" not in defn.name: defn.name += "@" + str(defn.line) + field_types = {key: source.field_type for (key, source) in field_sources.items()} + required_keys = {key for (key, source) in field_sources.items() if source.is_required} + readonly_keys = {key for (key, source) in field_sources.items() if source.is_readonly} info = self.build_typeddict_typeinfo( defn.name, field_types, required_keys, readonly_keys, defn.line, existing_info ) @@ -154,24 +161,25 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N else: self.fail("All bases of a new TypedDict must be TypedDict types", defn) - field_types = {} - required_keys = set() - readonly_keys = set() - # Iterate over bases in reverse order so that leftmost base class' keys take precedence - for base in reversed(typeddict_bases): - self.add_keys_and_types_from_base( - base, field_types, required_keys, readonly_keys, defn - ) - new_field_types, new_statements, new_required_keys, new_readonly_keys = ( - self.analyze_typeddict_classdef_fields(defn, oldfields=field_types) - ) - if new_field_types is None: + bases_info: list[tuple[TypeInfo, dict[str, Type]]] = [] + for base in typeddict_bases: + base_info = self.fetch_keys_and_types_from_base(base, defn) + if base_info is not None: + bases_info.append(base_info) + new_field_sources, new_statements = self.analyze_typeddict_classdef_fields(defn) + if new_field_sources is None: return True, None # Defer - field_types.update(new_field_types) - required_keys.update(new_required_keys) - readonly_keys.update(new_readonly_keys) + field_types, required_keys, readonly_keys, any_placeholders = ( + self.resolve_field_inheritance(bases_info, new_field_sources, defn) + ) info = self.build_typeddict_typeinfo( - defn.name, field_types, required_keys, readonly_keys, defn.line, existing_info + defn.name, + field_types, + required_keys, + readonly_keys, + defn.line, + existing_info, + analysis_incomplete=any_placeholders, ) defn.analyzed = TypedDictExpr(info) defn.analyzed.line = defn.line @@ -179,20 +187,15 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N defn.defs.body = new_statements return True, info - def add_keys_and_types_from_base( - self, - base: Expression, - field_types: dict[str, Type], - required_keys: set[str], - readonly_keys: set[str], - ctx: Context, - ) -> None: + def fetch_keys_and_types_from_base( + self, base: Expression, ctx: Context + ) -> tuple[TypeInfo, dict[str, Type]] | None: info = self._parse_typeddict_base(base, ctx) base_args: list[Type] = [] if isinstance(base, IndexExpr): args = self.analyze_base_args(base, ctx) if args is None: - return + return None base_args = args assert info.typeddict_type is not None @@ -211,13 +214,159 @@ def add_keys_and_types_from_base( with state.strict_optional_set(self.options.strict_optional): valid_items = self.map_items_to_base(valid_items, tvars, base_args) - for key in base_items: - if key in field_types: - self.fail(TYPEDDICT_OVERRIDE_MERGE.format(key), ctx) - field_types.update(valid_items) - required_keys.update(base_typed_dict.required_keys) - readonly_keys.update(base_typed_dict.readonly_keys) + return info, valid_items + + def field_sources_in_reverse_mro( + self, + bases: list[tuple[TypeInfo, dict[str, Type]]], + child_field_sources: dict[str, FieldSource], + ) -> dict[str, list[FieldSource]]: + """Find all keys in bases and child, mapping them to a list of sources. + + Iterate bases in reverse order to preserve key ordering for display. + """ + result: dict[str, list[FieldSource]] = {} + for base_info, base_fields in reversed(bases): + assert base_info.typeddict_type is not None + for field_name, field_type in base_fields.items(): + source = FieldSource( + base_name=base_info.name, + field_type=field_type, + is_readonly=field_name in base_info.typeddict_type.readonly_keys, + is_required=field_name in base_info.typeddict_type.required_keys, + ) + result.setdefault(field_name, []).append(source) + for field_name, source in child_field_sources.items(): + result.setdefault(field_name, []).append(source) + return result + + def primary_source(self, sources: list[FieldSource]) -> FieldSource: + """Select a primary source from a reverse-MRO-ordered list of sources. + + The primary source will be the last in the MRO list, skipping readonly + base class sources unless they are the only available option. + """ + if sources[-1].child_field_ctx: + return sources[-1] + mutable_sources = (s for s in reversed(sources) if not s.is_readonly) + return next(mutable_sources, sources[-1]) + + def verify_field_compatibility( + self, + field_name: str, + source: FieldSource, + field_type: Type, + is_readonly: bool, + is_required: bool, + primary_source_base_name: str | None, + ctx: Context, + ) -> None: + """Verify compatibility of the final child type field with a base class source. + + Verifies type compatibility, requiredness, and mutability. + """ + if has_placeholder(field_type) or has_placeholder(source.field_type): + # Cannot verify type compatibility yet, but analysis will be deferred + # and rerun later + is_type_compatible = True + elif source.is_readonly: + is_type_compatible = is_subtype(field_type, source.field_type) + else: + is_type_compatible = is_equivalent(field_type, source.field_type) + if not is_type_compatible: + if primary_source_base_name is None: + self.fail( + f'Definition of field "{field_name}" incompatible with base class "{source.base_name}"', + ctx, + ) + else: + self.fail( + f'Incompatible definitions of field "{field_name}" in base classes "{source.base_name}" and "{primary_source_base_name}"', + ctx, + ) + if is_readonly: + self.note( + f'This can be resolved by redeclaring the field "{field_name}" with a mutually compatible type', + ctx, + ) + elif source.is_required and not is_required: + if primary_source_base_name is None: + self.fail( + f'Field "{field_name}" is required in base class "{source.base_name}"', ctx + ) + else: + self.fail( + f'Field "{field_name}" is required in base class "{source.base_name}" but not in base class "{primary_source_base_name}"', + ctx, + ) + elif not source.is_required and not source.is_readonly and is_required: + if primary_source_base_name is None: + self.fail( + f'Field "{field_name}" is not required and not readonly in base class "{source.base_name}"', + ctx, + ) + else: + self.fail( + f'Field "{field_name}" is required in base class "{primary_source_base_name}" but not in base class "{source.base_name}"', + ctx, + ) + + def resolve_field_inheritance( + self, + bases: list[tuple[TypeInfo, dict[str, Type]]], + child_field_sources: dict[str, FieldSource], + ctx: Context, + ) -> tuple[dict[str, Type], set[str], set[str], bool]: + """Determine field types, requiredness, and readonlyness. + + Additionally returns if any placeholders were seen, as they will prevent full + analysis, but may not result in placeholders in the final type. + """ + field_sources = self.field_sources_in_reverse_mro(bases, child_field_sources) + field_types: dict[str, Type] = {} + required_keys: set[str] = set() + readonly_keys: set[str] = set() + any_placeholders = False + + for field_name, sources in field_sources.items(): + primary_source = self.primary_source(sources) + # If a read-only field is only defined in base classes, joining the types + # is unlikely to produce a tight enough result. We could check all the + # candidates from the base classes, but it would be O(n^2) complexity + # to find out which is a supertype of all the others. Instead, use the + # first definition in MRO order, and let the user provide the correct + # definition in the subclass if this fails. + field_types[field_name] = primary_source.field_type + + if primary_source.is_readonly: + # If the primary source is readonly, all sources are readonly + is_readonly = True + is_required = any(source.is_required for source in sources) + else: + is_readonly = False + is_required = primary_source.is_required + + if is_required: + required_keys.add(field_name) + if is_readonly: + readonly_keys.add(field_name) + + for source in sources: + if has_placeholder(source.field_type): + any_placeholders = True + if source is not primary_source: + self.verify_field_compatibility( + field_name, + source, + field_types[field_name], + is_readonly, + is_required, + primary_source.base_name, + primary_source.child_field_ctx or ctx, + ) + + return field_types, required_keys, readonly_keys, any_placeholders def _parse_typeddict_base(self, base: Expression, ctx: Context) -> TypeInfo: if isinstance(base, RefExpr): @@ -288,22 +437,19 @@ def map_items_to_base( return mapped_items def analyze_typeddict_classdef_fields( - self, defn: ClassDef, oldfields: Collection[str] | None = None - ) -> tuple[dict[str, Type] | None, list[Statement], set[str], set[str]]: + self, defn: ClassDef + ) -> tuple[dict[str, FieldSource] | None, list[Statement]]: """Analyze fields defined in a TypedDict class definition. This doesn't consider inherited fields (if any). Also consider totality, if given. Return tuple with these items: - * Dict of key -> type (or None if found an incomplete reference -> deferral) + * Dict of key -> field source (or None if found an incomplete reference -> deferral) * List of statements from defn.defs.body that are legally allowed to be a part of a TypedDict definition - * Set of required keys """ - fields: dict[str, Type] = {} - readonly_keys = set[str]() - required_keys = set[str]() + fields: dict[str, FieldSource] = {} statements: list[Statement] = [] total: bool | None = True @@ -333,8 +479,6 @@ def analyze_typeddict_classdef_fields( self.fail(TPDICT_CLASS_ERROR, stmt) else: name = stmt.lvalues[0].name - if name in (oldfields or []): - self.fail(f'Overwriting TypedDict field "{name}" while extending', stmt) if name in fields: self.fail(f'Duplicate TypedDict key "{name}"', stmt) continue @@ -353,18 +497,18 @@ def analyze_typeddict_classdef_fields( prohibit_special_class_field_types="TypedDict", ) if analyzed is None: - return None, [], set(), set() # Need to defer + return None, [] # Need to defer field_type = analyzed if not has_placeholder(analyzed): stmt.type = self.extract_meta_info(analyzed, stmt)[0] field_type, required, readonly = self.extract_meta_info(field_type) - fields[name] = field_type - - if (total or required is True) and required is not False: - required_keys.add(name) - if readonly: - readonly_keys.add(name) + fields[name] = FieldSource( + field_type=field_type, + is_required=(total or required is True) and required is not False, + is_readonly=readonly, + child_field_ctx=stmt, + ) # ...despite possible minor failures that allow further analysis. if stmt.type is None or hasattr(stmt, "new_syntax") and not stmt.new_syntax: @@ -373,7 +517,7 @@ def analyze_typeddict_classdef_fields( # x: int assigns rvalue to TempNode(AnyType()) self.fail("Right hand side values are not supported in TypedDict", stmt) - return fields, statements, required_keys, readonly_keys + return fields, statements def extract_meta_info( self, typ: Type, context: Context | None = None @@ -597,6 +741,7 @@ def build_typeddict_typeinfo( readonly_keys: set[str], line: int, existing_info: TypeInfo | None, + analysis_incomplete: bool = False, ) -> TypeInfo: # Prefer typing then typing_extensions if available. fallback = ( @@ -607,7 +752,8 @@ def build_typeddict_typeinfo( assert fallback is not None info = existing_info or self.api.basic_new_typeinfo(name, fallback, line) typeddict_type = TypedDictType(item_types, required_keys, readonly_keys, fallback) - if has_placeholder(typeddict_type): + if has_placeholder(typeddict_type) or analysis_incomplete: + typeddict_type.analysis_incomplete = True self.api.process_placeholder( None, "TypedDict item", info, force_progress=typeddict_type != info.typeddict_type ) diff --git a/mypy/types.py b/mypy/types.py index 40c3839e2efca..22d895d729a72 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -2950,6 +2950,7 @@ class TypedDictType(ProperType): "fallback", "extra_items_from", "to_be_mutated", + "analysis_incomplete", ) items: dict[str, Type] # item_name -> item_type @@ -2959,6 +2960,7 @@ class TypedDictType(ProperType): extra_items_from: list[ProperType] # only used during semantic analysis to_be_mutated: bool # only used in a plugin for `.update`, `|=`, etc + analysis_incomplete: bool # a placeholder type prevented complete analysis checking def __init__( self, @@ -2978,6 +2980,7 @@ def __init__( self.can_be_false = len(self.required_keys) == 0 self.extra_items_from = [] self.to_be_mutated = False + self.analysis_incomplete = False def accept(self, visitor: TypeVisitor[T]) -> T: return visitor.visit_typeddict_type(self) diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 4f4eb550ef04f..d8443006970d6 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -299,7 +299,7 @@ class Point1(TypedDict): x: int class Point2(TypedDict): x: float -class Bad(Point1, Point2): # E: Overwriting TypedDict field "x" while merging +class Bad(Point1, Point2): # E: Incompatible definitions of field "x" in base classes "Point2" and "Point1" pass b: Bad @@ -313,7 +313,7 @@ from typing import TypedDict class Point1(TypedDict): x: int class Point2(Point1): - x: float # E: Overwriting TypedDict field "x" while extending + x: float # E: Definition of field "x" incompatible with base class "Point1" p2: Point2 reveal_type(p2) # N: Revealed type is "TypedDict('__main__.Point2', {'x': builtins.float})" @@ -326,7 +326,7 @@ from typing import TypedDict class Point1(TypedDict): x: float class Point2(Point1): - x: int # E: Overwriting TypedDict field "x" while extending + x: int # E: Definition of field "x" incompatible with base class "Point1" p2: Point2 reveal_type(p2) # N: Revealed type is "TypedDict('__main__.Point2', {'x': builtins.int})" @@ -340,7 +340,7 @@ class Point1(TypedDict): x: int class Point2(TypedDict, total=False): x: int -class Bad(Point1, Point2): # E: Overwriting TypedDict field "x" while merging +class Bad(Point1, Point2): # E: Field "x" is required in base class "Point1" but not in base class "Point2" pass b: Bad @@ -354,10 +354,10 @@ from typing import TypedDict class Point1(TypedDict): x: int class Point2(Point1, total=False): - x: int # E: Overwriting TypedDict field "x" while extending + x: int # E: Field "x" is required in base class "Point1" p2: Point2 -reveal_type(p2) # N: Revealed type is "TypedDict('__main__.Point2', {'x': builtins.int})" +reveal_type(p2) # N: Revealed type is "TypedDict('__main__.Point2', {'x'?: builtins.int})" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] @@ -368,11 +368,11 @@ class Point1(TypedDict, total=False): x: int class Point2(TypedDict): x: int -class Bad(Point1, Point2): # E: Overwriting TypedDict field "x" while merging +class Bad(Point1, Point2): # E: Field "x" is required in base class "Point2" but not in base class "Point1" pass b: Bad -reveal_type(b) # N: Revealed type is "TypedDict('__main__.Bad', {'x': builtins.int})" +reveal_type(b) # N: Revealed type is "TypedDict('__main__.Bad', {'x'?: builtins.int})" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] @@ -382,7 +382,7 @@ from typing import TypedDict class Point1(TypedDict, total=False): x: int class Point2(Point1): - x: int # E: Overwriting TypedDict field "x" while extending + x: int # E: Field "x" is not required and not readonly in base class "Point1" p2: Point2 reveal_type(p2) # N: Revealed type is "TypedDict('__main__.Point2', {'x': builtins.int})" @@ -1818,9 +1818,11 @@ class Z("ZB"): pass class A(TypedDict): key: "X" class B(A): - key: "Y" # E: Overwriting TypedDict field "key" while extending + key: "Y" # E: Definition of field "key" incompatible with base class "A" class C(A): - key: "Z" # E: Overwriting TypedDict field "key" while extending + key: "Z" # E: Definition of field "key" incompatible with base class "A" +class D(A): + key: int # E: Definition of field "key" incompatible with base class "A" class XB: pass class YB(X): pass @@ -4372,7 +4374,7 @@ class A(TypedDict): key: ReadOnly[str] class B(A): - key: str # E: Overwriting TypedDict field "key" while extending + key: str a: A b: B @@ -4382,7 +4384,7 @@ def accepts_B(d: B): ... accepts_A(a) accepts_A(b) -accepts_B(a) +accepts_B(a) # E: Argument 1 to "accepts_B" has incompatible type "A"; expected "B" accepts_B(b) [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] @@ -4393,14 +4395,14 @@ from typing import ReadOnly, TypedDict class A(TypedDict): key: ReadOnly[float] class B(A): - key: int # E: Overwriting TypedDict field "key" while extending + key: int class C(A): - key: ReadOnly[int] # E: Overwriting TypedDict field "key" while extending + key: ReadOnly[int] a: A reveal_type(a) # N: Revealed type is "TypedDict('__main__.A', {'key'=: builtins.float})" b: B -reveal_type(b) # N: Revealed type is "TypedDict('__main__.B', {'key'=: builtins.int})" +reveal_type(b) # N: Revealed type is "TypedDict('__main__.B', {'key': builtins.int})" c: C reveal_type(c) # N: Revealed type is "TypedDict('__main__.C', {'key'=: builtins.int})" [builtins fixtures/dict.pyi] @@ -4416,9 +4418,9 @@ class Z("ZB"): pass class A(TypedDict): key: ReadOnly["X"] class B(A): - key: "Y" # E: Overwriting TypedDict field "key" while extending + key: "Y" # ok: Y subclasses X via YB class C(A): - key: "Z" # E: Overwriting TypedDict field "key" while extending + key: "Z" # E: Definition of field "key" incompatible with base class "A" class XB: pass class YB(X): pass @@ -4432,14 +4434,14 @@ from typing import ReadOnly, TypedDict class A(TypedDict): key: ReadOnly[int] class B(A): - key: float # E: Overwriting TypedDict field "key" while extending + key: float # E: Definition of field "key" incompatible with base class "A" class C(A): - key: ReadOnly[float] # E: Overwriting TypedDict field "key" while extending + key: ReadOnly[float] # E: Definition of field "key" incompatible with base class "A" a: A reveal_type(a) # N: Revealed type is "TypedDict('__main__.A', {'key'=: builtins.int})" b: B -reveal_type(b) # N: Revealed type is "TypedDict('__main__.B', {'key'=: builtins.float})" +reveal_type(b) # N: Revealed type is "TypedDict('__main__.B', {'key': builtins.float})" c: C reveal_type(c) # N: Revealed type is "TypedDict('__main__.C', {'key'=: builtins.float})" [builtins fixtures/dict.pyi] @@ -4454,17 +4456,17 @@ class A(TypedDict): class B(TypedDict): key: ReadOnly[int] -class C(B, A): # E: Overwriting TypedDict field "key" while merging +class C(B, A): # Left-most base "wins" pass -class D(A, B): # E: Overwriting TypedDict field "key" while merging +class D(A, B): # E: Incompatible definitions of field "key" in base classes "B" and "A" \ + # N: This can be resolved by redeclaring the field "key" with a mutually compatible type pass c: C reveal_type(c) # N: Revealed type is "TypedDict('__main__.C', {'key'=: builtins.int})" d: D reveal_type(d) # N: Revealed type is "TypedDict('__main__.D', {'key'=: builtins.float})" - [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] @@ -4475,12 +4477,12 @@ class A(TypedDict, total=False): key: ReadOnly[int] class B(A): - key: int # E: Overwriting TypedDict field "key" while extending + key: int a: A reveal_type(a) # N: Revealed type is "TypedDict('__main__.A', {'key'?=: builtins.int})" b: B -reveal_type(b) # N: Revealed type is "TypedDict('__main__.B', {'key'=: builtins.int})" +reveal_type(b) # N: Revealed type is "TypedDict('__main__.B', {'key': builtins.int})" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] @@ -4491,12 +4493,12 @@ class A(TypedDict): key: ReadOnly[int] class B(A, total=False): - key: int # E: Overwriting TypedDict field "key" while extending + key: int # E: Field "key" is required in base class "A" a: A reveal_type(a) # N: Revealed type is "TypedDict('__main__.A', {'key'=: builtins.int})" b: B -reveal_type(b) # N: Revealed type is "TypedDict('__main__.B', {'key'=: builtins.int})" +reveal_type(b) # N: Revealed type is "TypedDict('__main__.B', {'key'?: builtins.int})" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] @@ -4509,9 +4511,9 @@ class A(TypedDict, total=True): class B(TypedDict, total=False): key: ReadOnly[int] -class C(B, A): # E: Overwriting TypedDict field "key" while merging +class C(B, A): pass -class D(A, B): # E: Overwriting TypedDict field "key" while merging +class D(A, B): pass c: C @@ -4533,24 +4535,24 @@ class B(TypedDict): class C(TypedDict): key: int -class D1(A, B): pass # E: Overwriting TypedDict field "key" while merging +class D1(A, B): pass d1: D1 -reveal_type(d1) # N: Revealed type is "TypedDict('__main__.D1', {'key'=: builtins.float})" -class D2(B, A): pass # E: Overwriting TypedDict field "key" while merging +reveal_type(d1) # N: Revealed type is "TypedDict('__main__.D1', {'key': builtins.float})" +class D2(B, A): pass d2: D2 -reveal_type(d2) # N: Revealed type is "TypedDict('__main__.D2', {'key'=: builtins.float})" -class D3(A, C): pass # E: Overwriting TypedDict field "key" while merging +reveal_type(d2) # N: Revealed type is "TypedDict('__main__.D2', {'key': builtins.float})" +class D3(A, C): pass d3: D3 -reveal_type(d3) # N: Revealed type is "TypedDict('__main__.D3', {'key'=: builtins.float})" -class D4(C, A): pass # E: Overwriting TypedDict field "key" while merging +reveal_type(d3) # N: Revealed type is "TypedDict('__main__.D3', {'key': builtins.int})" +class D4(C, A): pass d4: D4 -reveal_type(d4) # N: Revealed type is "TypedDict('__main__.D4', {'key'=: builtins.int})" -class D5(A, B, C): pass # E: Overwriting TypedDict field "key" while merging +reveal_type(d4) # N: Revealed type is "TypedDict('__main__.D4', {'key': builtins.int})" +class D5(A, B, C): pass # E: Incompatible definitions of field "key" in base classes "C" and "B" d5: D5 -reveal_type(d5) # N: Revealed type is "TypedDict('__main__.D5', {'key'=: builtins.float})" -class D6(C, B, A): pass # E: Overwriting TypedDict field "key" while merging +reveal_type(d5) # N: Revealed type is "TypedDict('__main__.D5', {'key': builtins.float})" +class D6(C, B, A): pass # E: Incompatible definitions of field "key" in base classes "B" and "C" d6: D6 -reveal_type(d6) # N: Revealed type is "TypedDict('__main__.D6', {'key'=: builtins.int})" +reveal_type(d6) # N: Revealed type is "TypedDict('__main__.D6', {'key': builtins.int})" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] From d8c619dd3a32a93394e10e1d100a2ddb8d14d4d6 Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Thu, 30 Apr 2026 12:13:35 +0100 Subject: [PATCH 09/24] Fix TypeAnalyser to retain readonly keys The fallback path in TypeAnalyser::visit_typeddict_type is copying the required keys from the original type, but not the readonly keys. This is resulting in incorrect subtyping analysis for type variables with TypedDict bounds with readonly keys. --- mypy/typeanal.py | 1 + test-data/unit/check-typeddict.test | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index db56256192625..3885d65bd1e98 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -1360,6 +1360,7 @@ def visit_typeddict_type(self, t: TypedDictType) -> Type: if sub_item_name in p_analyzed.readonly_keys: readonly_keys.add(sub_item_name) else: + readonly_keys = t.readonly_keys required_keys = t.required_keys fallback = t.fallback return TypedDictType(items, required_keys, readonly_keys, fallback, t.line, t.column) diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index d8443006970d6..b980d56d1a692 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -4367,6 +4367,23 @@ accepts_B(b) [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testTypedDictReadOnlyTypeVarBoundSubtyping] +from typing import ReadOnly, TypedDict, TypeVar +A = TypedDict('A', {'x': ReadOnly[int]}) +B = TypedDict('B', {'x': int}) +TA = TypeVar('TA', bound=A) +TB = TypeVar('TB', bound=B) +def fA(t: TA) -> TA: return t +def fB(t: TB) -> TB: return t +a: A +b: B +fA(a) +fA(b) +fB(a) # E: Value of type variable "TB" of "fB" cannot be "A" +fB(b) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testTypedDictReadOnlySubclassing] from typing import ReadOnly, TypedDict From e7372e4f89b8fdb752db5dae3ad6d66990c5ba2f Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Thu, 30 Apr 2026 08:52:00 +0100 Subject: [PATCH 10/24] Narrow on explicitly uninhabited keys `NotRequired[Never]` can be used to indicate that a single key in a TypedDict will not be present. --- mypy/checker.py | 8 +- test-data/unit/check-typeddict.test | 186 ++++++++++++++++++++++++++-- 2 files changed, 184 insertions(+), 10 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 4d4f376f25dda..dac9252b8f394 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6410,7 +6410,13 @@ def conditional_types_for_iterable( if key in possible_iterable_type.required_keys: if_types.append(possible_iterable_type) elif ( - key in possible_iterable_type.items or not possible_iterable_type.is_final + key in possible_iterable_type.items + and not isinstance( + get_proper_type(possible_iterable_type.items[key]), UninhabitedType + ) + ) or ( + key not in possible_iterable_type.items + and not possible_iterable_type.is_final ): if_types.append(possible_iterable_type) else_types.append(possible_iterable_type) diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index b980d56d1a692..66201204f2851 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -2409,6 +2409,26 @@ v = {bad2: 2} # E: Missing key "num" for TypedDict "Value" \ [case testOperatorContainsNarrowsTypedDicts_unionWithList] from __future__ import annotations +from typing import assert_type, TypedDict, Union + +class D(TypedDict): + foo: int + + +d_or_list: D | list[str] + +if 'foo' in d_or_list: + assert_type(d_or_list, Union[D, list[str]]) +elif 'bar' in d_or_list: + assert_type(d_or_list, list[str]) +else: + assert_type(d_or_list, list[str]) + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testOperatorContainsNarrowsTypedDicts_unionWithList_final] +from __future__ import annotations from typing import assert_type, final, TypedDict, Union @final @@ -2428,20 +2448,67 @@ else: [builtins fixtures/dict.pyi] [typing fixtures/typing-full.pyi] -[case testOperatorContainsNarrowsTypedDicts_total] +[case testOperatorContainsNarrowsTypedDicts_total_never] from __future__ import annotations from typing import assert_type, final, Literal, TypedDict, TypeVar, Union +from typing_extensions import Never, NotRequired -@final class D1(TypedDict): foo: int + bar: NotRequired[Never] + invalid: NotRequired[Never] + +class D2(TypedDict): + foo: NotRequired[Never] + bar: int + invalid: NotRequired[Never] + +d: D1 | D2 + +if 'foo' in d: + assert_type(d, D1) +else: + assert_type(d, D2) + +foo_or_bar: Literal['foo', 'bar'] +if foo_or_bar in d: + assert_type(d, Union[D1, D2]) +else: + assert_type(d, Union[D1, D2]) + +foo_or_invalid: Literal['foo', 'invalid'] +if foo_or_invalid in d: + assert_type(d, D1) + # won't narrow 'foo_or_invalid' + assert_type(foo_or_invalid, Literal['foo', 'invalid']) +else: + assert_type(d, Union[D1, D2]) + # won't narrow 'foo_or_invalid' + assert_type(foo_or_invalid, Literal['foo', 'invalid']) + +TD = TypeVar('TD', D1, D2) + +def f(arg: TD) -> None: + value: int + if 'foo' in arg: + assert_type(arg['foo'], int) + else: + assert_type(arg['bar'], int) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] +[case testOperatorContainsNarrowsTypedDicts_total_final] +from __future__ import annotations +from typing import assert_type, final, Literal, TypedDict, TypeVar, Union + +@final +class D1(TypedDict): + foo: int @final class D2(TypedDict): bar: int - d: D1 | D2 if 'foo' in d: @@ -2473,7 +2540,49 @@ def f(arg: TD) -> None: assert_type(arg['foo'], int) else: assert_type(arg['bar'], int) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testOperatorContainsNarrowsTypedDicts_never] +# flags: --warn-unreachable +from __future__ import annotations +from typing import assert_type, TypedDict, Union +from typing_extensions import Never, NotRequired + +class DFooNotBar(TypedDict): + foo: int + bar: NotRequired[Never] + + +class DBar(TypedDict): + bar: int + + +d_bar: DBar + +if 'bar' in d_bar: + assert_type(d_bar, DBar) +else: + spam = 'ham' # E: Statement is unreachable + +if 'spam' in d_bar: + assert_type(d_bar, DBar) +else: + assert_type(d_bar, DBar) + +d_foo_not_bar: DFooNotBar +if 'spam' in d_foo_not_bar: + spam = 'ham' +else: + assert_type(d_foo_not_bar, DFooNotBar) + +d_union: DFooNotBar | DBar + +if 'foo' in d_union: + assert_type(d_union, Union[DFooNotBar, DBar]) +else: + assert_type(d_union, DBar) [builtins fixtures/dict.pyi] [typing fixtures/typing-full.pyi] @@ -2521,7 +2630,41 @@ else: [builtins fixtures/dict.pyi] [typing fixtures/typing-full.pyi] -[case testOperatorContainsNarrowsTypedDicts_partialThroughTotalFalse] +[case testOperatorContainsNarrowsTypedDicts_partialThroughTotalFalse_never] +from __future__ import annotations +from typing import assert_type, Literal, TypedDict, Union +from typing_extensions import Never, NotRequired + +class DTotal(TypedDict): + required_key: int + optional_key: NotRequired[Never] + +class DNotTotal(TypedDict, total=False): + required_key: Never + optional_key: int + +d: DTotal | DNotTotal + +if 'required_key' in d: + assert_type(d, DTotal) +else: + assert_type(d, DNotTotal) + +if 'optional_key' in d: + assert_type(d, DNotTotal) +else: + assert_type(d, Union[DTotal, DNotTotal]) + +key: Literal['optional_key', 'required_key'] +if key in d: + assert_type(d, Union[DTotal, DNotTotal]) +else: + assert_type(d, Union[DTotal, DNotTotal]) + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testOperatorContainsNarrowsTypedDicts_partialThroughTotalFalse_final] from __future__ import annotations from typing import assert_type, final, Literal, TypedDict, Union @@ -2529,12 +2672,10 @@ from typing import assert_type, final, Literal, TypedDict, Union class DTotal(TypedDict): required_key: int - @final class DNotTotal(TypedDict, total=False): optional_key: int - d: DTotal | DNotTotal if 'required_key' in d: @@ -2556,7 +2697,36 @@ else: [builtins fixtures/dict.pyi] [typing fixtures/typing-full.pyi] -[case testOperatorContainsNarrowsTypedDicts_partialThroughNotRequired] +[case testOperatorContainsNarrowsTypedDicts_partialThroughNotRequired_never] +from __future__ import annotations +from typing import assert_type, final, TypedDict, Union +from typing_extensions import Never, Required, NotRequired + +class D1(TypedDict): + required_key: Required[int] + optional_key: NotRequired[int] + +class D2(TypedDict): + abc: int + xyz: int + required_key: NotRequired[Never] + optional_key: NotRequired[Never] + +d: D1 | D2 + +if 'required_key' in d: + assert_type(d, D1) +else: + assert_type(d, D2) + +if 'optional_key' in d: + assert_type(d, D1) +else: + assert_type(d, Union[D1, D2]) + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] +[case testOperatorContainsNarrowsTypedDicts_partialThroughNotRequired_final] from __future__ import annotations from typing import assert_type, final, TypedDict, Union from typing_extensions import Required, NotRequired @@ -2566,13 +2736,11 @@ class D1(TypedDict): required_key: Required[int] optional_key: NotRequired[int] - @final class D2(TypedDict): abc: int xyz: int - d: D1 | D2 if 'required_key' in d: From c7115c1c5c688e56b69f4ae6dc726e6e65340c9b Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Thu, 23 Apr 2026 15:25:15 +0100 Subject: [PATCH 11/24] Support parsing TypedDict closed keyword Begin implementing PEP 728 support by adding an is_closed field to TypedDictType. This is filled out from the closed keyword, and displayed in reveal_type, but not otherwise supported yet. --- mypy/copytype.py | 2 +- mypy/expandtype.py | 3 +- mypy/exprtotype.py | 2 +- mypy/fastparse.py | 2 +- mypy/join.py | 3 +- mypy/meet.py | 3 +- mypy/nativeparse.py | 2 +- mypy/semanal_typeddict.py | 74 +++++++++++++++++++++-------- mypy/type_visitor.py | 1 + mypy/typeanal.py | 5 +- mypy/types.py | 23 ++++++++- test-data/unit/check-typeddict.test | 64 +++++++++++++++++++++++++ 12 files changed, 154 insertions(+), 30 deletions(-) diff --git a/mypy/copytype.py b/mypy/copytype.py index 9a390a01bdbab..3ec512193bece 100644 --- a/mypy/copytype.py +++ b/mypy/copytype.py @@ -107,7 +107,7 @@ def visit_tuple_type(self, t: TupleType) -> ProperType: def visit_typeddict_type(self, t: TypedDictType) -> ProperType: return self.copy_common( - t, TypedDictType(t.items, t.required_keys, t.readonly_keys, t.fallback) + t, TypedDictType(t.items, t.required_keys, t.readonly_keys, t.is_closed, t.fallback) ) def visit_literal_type(self, t: LiteralType) -> ProperType: diff --git a/mypy/expandtype.py b/mypy/expandtype.py index 5790b717172ac..ba2b90e4a2533 100644 --- a/mypy/expandtype.py +++ b/mypy/expandtype.py @@ -357,7 +357,8 @@ def _possible_callable_kwargs(cls, repl: Parameters, dict_type: Instance) -> Pro if not kwargs: return Instance(dict_type.type, [dict_type.args[0], extra_items]) # TODO: when PEP 728 is implemented, pass extra_items below. - return TypedDictType(kwargs, required_names, set(), fallback=dict_type) + # TODO: implement closure analysis + return TypedDictType(kwargs, required_names, set(), False, fallback=dict_type) def visit_type_var_tuple(self, t: TypeVarTupleType) -> Type: # Sometimes solver may need to expand a type variable with (a copy of) itself diff --git a/mypy/exprtotype.py b/mypy/exprtotype.py index 5a22b9c3c759a..26c865d64eb4b 100644 --- a/mypy/exprtotype.py +++ b/mypy/exprtotype.py @@ -277,7 +277,7 @@ def expr_to_unanalyzed_type( value, options, allow_new_syntax, expr, lookup_qualified=lookup_qualified ) result = TypedDictType( - items, set(), set(), Instance(MISSING_FALLBACK, ()), expr.line, expr.column + items, set(), set(), False, Instance(MISSING_FALLBACK, ()), expr.line, expr.column ) result.extra_items_from = extra_items_from return result diff --git a/mypy/fastparse.py b/mypy/fastparse.py index d9e2d5df8f4c1..c2d2243d7555e 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -2143,7 +2143,7 @@ def visit_Dict(self, n: ast3.Dict) -> Type: continue return self.invalid_type(n) items[item_name.value] = self.visit(value) - result = TypedDictType(items, set(), set(), _dummy_fallback, n.lineno, n.col_offset) + result = TypedDictType(items, set(), set(), False, _dummy_fallback, n.lineno, n.col_offset) result.extra_items_from = extra_items_from return result diff --git a/mypy/join.py b/mypy/join.py index aca3dd3b097a3..db225fadebb1b 100644 --- a/mypy/join.py +++ b/mypy/join.py @@ -638,7 +638,8 @@ def visit_typeddict_type(self, t: TypedDictType) -> ProperType: fallback = self.s.create_anonymous_fallback() required_keys = t.required_keys & self.s.required_keys - return TypedDictType(items, required_keys, readonly_keys, fallback) + # TODO: Implement closure analysis + return TypedDictType(items, required_keys, readonly_keys, False, fallback) elif isinstance(self.s, Instance): return join_types(self.s, t.fallback) else: diff --git a/mypy/meet.py b/mypy/meet.py index ac184d21ddbd7..057f7d4027cbd 100644 --- a/mypy/meet.py +++ b/mypy/meet.py @@ -1172,7 +1172,8 @@ def visit_typeddict_type(self, t: TypedDictType) -> ProperType: fallback = self.s.create_anonymous_fallback() required_keys = self.s.required_keys | t.required_keys - return TypedDictType(items, required_keys, readonly_keys, fallback) + # TODO: implement closure analysis + return TypedDictType(items, required_keys, readonly_keys, False, fallback) elif isinstance(self.s, Instance) and is_subtype(t, self.s): return t else: diff --git a/mypy/nativeparse.py b/mypy/nativeparse.py index d048e9bce65e2..06112e226c4d2 100644 --- a/mypy/nativeparse.py +++ b/mypy/nativeparse.py @@ -919,7 +919,7 @@ def read_type(state: State, data: ReadBuffer) -> Type: extra_items_from.append(val) else: td_items[key] = val - typeddict_type = TypedDictType(td_items, set(), set(), _dummy_fallback) + typeddict_type = TypedDictType(td_items, set(), set(), False, _dummy_fallback) typeddict_type.extra_items_from = extra_items_from read_loc(data, typeddict_type) expect_end_tag(data) diff --git a/mypy/semanal_typeddict.py b/mypy/semanal_typeddict.py index 8aac2a81d0eaf..2d33cb679c9b4 100644 --- a/mypy/semanal_typeddict.py +++ b/mypy/semanal_typeddict.py @@ -108,6 +108,12 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N if isinstance(defn.analyzed, TypedDictExpr): existing_info = defn.analyzed.info + is_closed: bool = False + if "closed" in defn.keywords: + is_closed = require_bool_literal_argument( + self.api, defn.keywords["closed"], "closed", False + ) + if ( len(defn.base_type_exprs) == 1 and isinstance(defn.base_type_exprs[0], RefExpr) @@ -123,7 +129,13 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N required_keys = {key for (key, source) in field_sources.items() if source.is_required} readonly_keys = {key for (key, source) in field_sources.items() if source.is_readonly} info = self.build_typeddict_typeinfo( - defn.name, field_types, required_keys, readonly_keys, defn.line, existing_info + defn.name, + field_types, + required_keys, + readonly_keys, + is_closed, + defn.line, + existing_info, ) defn.analyzed = TypedDictExpr(info) defn.analyzed.line = defn.line @@ -169,6 +181,7 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N new_field_sources, new_statements = self.analyze_typeddict_classdef_fields(defn) if new_field_sources is None: return True, None # Defer + # TODO: Implement closure analysis field_types, required_keys, readonly_keys, any_placeholders = ( self.resolve_field_inheritance(bases_info, new_field_sources, defn) ) @@ -177,6 +190,7 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N field_types, required_keys, readonly_keys, + is_closed, defn.line, existing_info, analysis_incomplete=any_placeholders, @@ -459,6 +473,8 @@ def analyze_typeddict_classdef_fields( self.api, defn.keywords["total"], "total", True ) continue + elif key == "closed": + continue for_function = ' for "__init_subclass__" of "TypedDict"' self.msg.unexpected_keyword_argument_for_function(for_function, key, defn) @@ -577,7 +593,7 @@ def check_typeddict( # This is a valid typed dict, but some type is not ready. # The caller should defer this until next iteration. return True, None, [] - name, items, wrapped_types, total, tvar_defs, ok = res + name, items, wrapped_types, total, closed, tvar_defs, ok = res if not ok: # Error. Construct dummy return value. if var_name: @@ -586,7 +602,7 @@ def check_typeddict( name += "@" + str(call.line) else: name = var_name = "TypedDict@" + str(call.line) - info = self.build_typeddict_typeinfo(name, {}, set(), set(), call.line, None) + info = self.build_typeddict_typeinfo(name, {}, set(), set(), False, call.line, None) else: if var_name is not None and name != var_name: self.fail( @@ -630,6 +646,7 @@ def check_typeddict( dict(zip(items, types)), required_keys, readonly_keys, + closed, call.line, existing_info, ) @@ -645,25 +662,33 @@ def check_typeddict( def parse_typeddict_args( self, call: CallExpr - ) -> tuple[str, list[str], list[Type], bool, list[TypeVarLikeType], bool] | None: + ) -> tuple[str, list[str], list[Type], bool, bool, list[TypeVarLikeType], bool] | None: """Parse typed dict call expression. - Return names, types, totality, was there an error during parsing. + Return names, types, totality, open/closed, was there an error during parsing. If some type is not ready, return None. """ # TODO: Share code with check_argument_count in checkexpr.py? args = call.args if len(args) < 2: return self.fail_typeddict_arg("Too few arguments for TypedDict()", call) - if len(args) > 3: + if len(args) > 4: return self.fail_typeddict_arg("Too many arguments for TypedDict()", call) - # TODO: Support keyword arguments - if call.arg_kinds not in ([ARG_POS, ARG_POS], [ARG_POS, ARG_POS, ARG_NAMED]): + if call.arg_kinds[:2] != [ARG_POS, ARG_POS] or any( + arg_kind != ARG_NAMED for arg_kind in call.arg_kinds[2:] + ): return self.fail_typeddict_arg("Unexpected arguments to TypedDict()", call) - if len(args) == 3 and call.arg_names[2] != "total": - return self.fail_typeddict_arg( - f'Unexpected keyword argument "{call.arg_names[2]}" for "TypedDict"', call - ) + seen_arg_names = set() + for arg_name in call.arg_names[2:]: + if arg_name not in ("total", "closed"): + return self.fail_typeddict_arg( + f'Unexpected keyword argument "{arg_name}" for "TypedDict"', call + ) + if arg_name in seen_arg_names: + return self.fail_typeddict_arg( + f'Repeated keyword argument "{arg_name}" for "TypedDict"', call + ) + seen_arg_names.add(arg_name) if not isinstance(args[0], StrExpr): return self.fail_typeddict_arg( "TypedDict() expects a string literal as the first argument", call @@ -673,10 +698,16 @@ def parse_typeddict_args( "TypedDict() expects a dictionary literal as the second argument", call ) total: bool | None = True - if len(args) == 3: - total = require_bool_literal_argument(self.api, call.args[2], "total") - if total is None: - return "", [], [], True, [], False + closed: bool = False + for arg_name, arg in zip(call.arg_names[2:], call.args[2:]): + assert arg_name + value = require_bool_literal_argument(self.api, arg, arg_name) + if value is None: + return "", [], [], True, False, [], False + if arg_name == "closed": + closed = value + else: + total = value dictexpr = args[1] tvar_defs = self.api.get_and_bind_all_tvars([t for k, t in dictexpr.items]) res = self.parse_typeddict_fields_with_types(dictexpr.items) @@ -685,7 +716,7 @@ def parse_typeddict_args( return None items, types, ok = res assert total is not None - return args[0].value, items, types, total, tvar_defs, ok + return args[0].value, items, types, total, closed, tvar_defs, ok def parse_typeddict_fields_with_types( self, dict_items: list[tuple[Expression | None, Expression]] @@ -729,9 +760,9 @@ def parse_typeddict_fields_with_types( def fail_typeddict_arg( self, message: str, context: Context - ) -> tuple[str, list[str], list[Type], bool, list[TypeVarLikeType], bool]: + ) -> tuple[str, list[str], list[Type], bool, bool, list[TypeVarLikeType], bool]: self.fail(message, context) - return "", [], [], True, [], False + return "", [], [], True, False, [], False def build_typeddict_typeinfo( self, @@ -739,6 +770,7 @@ def build_typeddict_typeinfo( item_types: dict[str, Type], required_keys: set[str], readonly_keys: set[str], + is_closed: bool, line: int, existing_info: TypeInfo | None, analysis_incomplete: bool = False, @@ -751,7 +783,9 @@ def build_typeddict_typeinfo( ) assert fallback is not None info = existing_info or self.api.basic_new_typeinfo(name, fallback, line) - typeddict_type = TypedDictType(item_types, required_keys, readonly_keys, fallback) + typeddict_type = TypedDictType( + item_types, required_keys, readonly_keys, is_closed, fallback + ) if has_placeholder(typeddict_type) or analysis_incomplete: typeddict_type.analysis_incomplete = True self.api.process_placeholder( diff --git a/mypy/type_visitor.py b/mypy/type_visitor.py index 1b38481ba0004..8467ac6dc6e79 100644 --- a/mypy/type_visitor.py +++ b/mypy/type_visitor.py @@ -278,6 +278,7 @@ def visit_typeddict_type(self, t: TypedDictType, /) -> Type: items, t.required_keys, t.readonly_keys, + t.is_closed, # TODO: This appears to be unsafe. cast(Any, t.fallback.accept(self)), t.line, diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 3885d65bd1e98..d121f2c6c0ec5 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -1363,7 +1363,10 @@ def visit_typeddict_type(self, t: TypedDictType) -> Type: readonly_keys = t.readonly_keys required_keys = t.required_keys fallback = t.fallback - return TypedDictType(items, required_keys, readonly_keys, fallback, t.line, t.column) + # TODO: Implement closure analysis + return TypedDictType( + items, required_keys, readonly_keys, False, fallback, t.line, t.column + ) def visit_raw_expression_type(self, t: RawExpressionType) -> Type: # We should never see a bare Literal. We synthesize these raw literals diff --git a/mypy/types.py b/mypy/types.py index 22d895d729a72..4ef333fc58894 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -2947,6 +2947,7 @@ class TypedDictType(ProperType): "items", "required_keys", "readonly_keys", + "is_closed", "fallback", "extra_items_from", "to_be_mutated", @@ -2967,6 +2968,7 @@ def __init__( items: dict[str, Type], required_keys: set[str], readonly_keys: set[str], + is_closed: bool, fallback: Instance, line: int = -1, column: int = -1, @@ -2975,6 +2977,7 @@ def __init__( self.items = items self.required_keys = required_keys self.readonly_keys = readonly_keys + self.is_closed = is_closed self.fallback = fallback self.can_be_true = len(self.items) > 0 self.can_be_false = len(self.required_keys) == 0 @@ -2992,6 +2995,7 @@ def __hash__(self) -> int: self.fallback, frozenset(self.required_keys), frozenset(self.readonly_keys), + self.is_closed, ) ) @@ -3009,6 +3013,7 @@ def __eq__(self, other: object) -> bool: and self.fallback == other.fallback and self.required_keys == other.required_keys and self.readonly_keys == other.readonly_keys + and self.is_closed == other.is_closed ) def serialize(self) -> JsonDict: @@ -3023,10 +3028,12 @@ def serialize(self) -> JsonDict: @classmethod def deserialize(cls, data: JsonDict) -> TypedDictType: assert data[".class"] == "TypedDictType" + # TODO: round-trip is_closed return TypedDictType( {n: deserialize_type(t) for (n, t) in data["items"]}, set(data["required_keys"]), set(data["readonly_keys"]), + False, Instance.deserialize(data["fallback"]), ) @@ -3042,8 +3049,13 @@ def write(self, data: WriteBuffer) -> None: def read(cls, data: ReadBuffer) -> TypedDictType: assert read_tag(data) == INSTANCE fallback = Instance.read(data) + # TODO: round-trip is_closed ret = TypedDictType( - read_type_map(data), set(read_str_list(data)), set(read_str_list(data)), fallback + read_type_map(data), + set(read_str_list(data)), + set(read_str_list(data)), + False, + fallback, ) assert read_tag(data) == END_TAG return ret @@ -3069,6 +3081,7 @@ def copy_modified( item_names: list[str] | None = None, required_keys: set[str] | None = None, readonly_keys: set[str] | None = None, + is_closed: bool | None = None, ) -> TypedDictType: if fallback is None: fallback = self.fallback @@ -3080,10 +3093,14 @@ def copy_modified( required_keys = self.required_keys if readonly_keys is None: readonly_keys = self.readonly_keys + if is_closed is None: + is_closed = self.is_closed if item_names is not None: items = {k: v for (k, v) in items.items() if k in item_names} required_keys &= set(item_names) - return TypedDictType(items, required_keys, readonly_keys, fallback, self.line, self.column) + return TypedDictType( + items, required_keys, readonly_keys, is_closed, fallback, self.line, self.column + ) def names_are_wider_than(self, other: TypedDictType) -> bool: return len(other.items.keys() - self.items.keys()) == 0 @@ -3943,6 +3960,8 @@ def item_str(name: str, typ: str) -> str: + ", ".join(item_str(name, typ.accept(self)) for name, typ in t.items.items()) + "}" ) + if t.is_closed: + s += ", closed=True" prefix = "" if t.fallback and t.fallback.type: if t.fallback.type.fullname not in TPDICT_FB_NAMES: diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 66201204f2851..449aee9495d24 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -1420,6 +1420,7 @@ A = TypedDict('A', {'x': int}, total=0) # E: "total" argument must be a True or B = TypedDict('B', {'x': int}, total=bool) # E: "total" argument must be a True or False literal C = TypedDict('C', {'x': int}, x=False) # E: Unexpected keyword argument "x" for "TypedDict" D = TypedDict('D', {'x': int}, False) # E: Unexpected arguments to TypedDict() +E = TypedDict('E', {'x': int}, total=False, x=False) # E: Unexpected keyword argument "x" for "TypedDict" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] @@ -5065,6 +5066,69 @@ reveal_type(j(c, b)) # N: Revealed type is "TypedDict({'x'?=: builtins.int})" [typing fixtures/typing-typeddict.pyi] + +# Closed +# See https://peps.python.org/pep-0728/ + +[case testTypedDictWithClosedFalse] +from typing import TypedDict +D = TypedDict('D', {'x': int, 'y': str}, closed=False) +d: D +reveal_type(d) # N: Revealed type is "TypedDict('__main__.D', {'x': builtins.int, 'y': builtins.str})" +class E(TypedDict, closed=False): + x: int + y: str +e: E +reveal_type(e) # N: Revealed type is "TypedDict('__main__.E', {'x': builtins.int, 'y': builtins.str})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictWithClosedTrue] +from typing import TypedDict +D = TypedDict('D', {'x': int, 'y': str}, closed=True) +d: D +reveal_type(d) # N: Revealed type is "TypedDict('__main__.D', {'x': builtins.int, 'y': builtins.str}, closed=True)" +class E(TypedDict, closed=True): + x: int + y: str +e: E +reveal_type(e) # N: Revealed type is "TypedDict('__main__.E', {'x': builtins.int, 'y': builtins.str}, closed=True)" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictWithInvalidClosedArgument] +from typing import TypedDict +A = TypedDict('A', {'x': int}, closed=0) # E: "closed" argument must be a True or False literal +B = TypedDict('B', {'x': int}, closed=bool) # E: "closed" argument must be a True or False literal +class C(TypedDict, closed=0): # E: "closed" argument must be a True or False literal + x: int +class D(TypedDict, closed=bool): # E: "closed" argument must be a True or False literal + x: int +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictWithClosedAndTotal] +from typing import TypedDict +A = TypedDict('A', {'x': int, 'y': str}, total=False, closed=True) +B = TypedDict('B', {'x': int, 'y': str}, closed=True, total=False) +C = TypedDict('C', {'x': int, 'y': str}, total=True, closed=False) +a: A +b: B +c: C +reveal_type(a) # N: Revealed type is "TypedDict('__main__.A', {'x'?: builtins.int, 'y'?: builtins.str}, closed=True)" +reveal_type(b) # N: Revealed type is "TypedDict('__main__.B', {'x'?: builtins.int, 'y'?: builtins.str}, closed=True)" +reveal_type(c) # N: Revealed type is "TypedDict('__main__.C', {'x': builtins.int, 'y': builtins.str})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictWithDuplicateKeywordArguments_no_parallel] +from typing import TypedDict +A = TypedDict('A', {'x': int}, closed=True, closed=False) # E: Repeated keyword argument "closed" for "TypedDict" +B = TypedDict('B', {'x': int}, total=True, total=False) # E: Repeated keyword argument "total" for "TypedDict" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + + [case testTypedDictFinalAndClassVar] from typing import TypedDict, Final, ClassVar From bce4b17eb6a381b8e2d3ec27b227dc033e373cb3 Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Mon, 27 Apr 2026 09:06:39 +0100 Subject: [PATCH 12/24] Implement closed support in TypedDict subtyping Continue implementing PEP 728 support with updates to the subtyping logic. Drop the previous use of 'names_are_wider_than', which will be difficult to extend to cover `extra_items`, and instead use zipall to check for key addition/removal alongside the other key-based checks. --- mypy/subtypes.py | 17 ++++++-- mypy/types.py | 3 -- test-data/unit/check-typeddict.test | 67 +++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 7 deletions(-) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 828dcfd195b76..1675f77e90d59 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -921,16 +921,25 @@ def visit_typeddict_type(self, left: TypedDictType) -> bool: elif isinstance(right, TypedDictType): if left == right: return True # Fast path - if not left.names_are_wider_than(right): + + # A closed type must remain closed + if right.is_closed and not left.is_closed: return False # Perform fast key-based checks before recursing into value types - for name in right.items: + for name, l_type, r_type in left.zipall(right): l_required = name in left.required_keys r_required = name in right.required_keys - l_mutable = name not in left.readonly_keys - r_mutable = name not in right.readonly_keys + l_mutable = l_type is not None and name not in left.readonly_keys + r_mutable = r_type is not None and name not in right.readonly_keys + # New keys cannot be added to a closed supertype + if r_type is None and right.is_closed: + return False + # Keys cannot be dropped in an open subtype + # as this would implicitly widen the type constraint + if l_type is None and not left.is_closed: + return False # Required keys must remain required if r_required and not l_required: return False diff --git a/mypy/types.py b/mypy/types.py index 4ef333fc58894..8da7a313ee0ec 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -3102,9 +3102,6 @@ def copy_modified( items, required_keys, readonly_keys, is_closed, fallback, self.line, self.column ) - def names_are_wider_than(self, other: TypedDictType) -> bool: - return len(other.items.keys() - self.items.keys()) == 0 - def zip(self, right: TypedDictType) -> Iterable[tuple[str, Type, Type]]: left = self for item_name, left_item_type in left.items.items(): diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 449aee9495d24..464b2591da335 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -5128,6 +5128,73 @@ B = TypedDict('B', {'x': int}, total=True, total=False) # E: Repeated keyword a [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testTypedDictSubtypingClosedMustRemainClosed] +from typing import TypedDict +A = TypedDict('A', {'x': int}, closed=True) +B = TypedDict('B', {'x': int}, closed=True) +C = TypedDict('C', {'x': int}, closed=False) +D = TypedDict('D', {'x': int}, closed=False) +def f(x: A) -> None: pass +def g(x: C) -> None: pass +a: A +b: B +c: C +d: D +f(b) +f(d) # E: Argument 1 to "f" has incompatible type "D"; expected "A" +g(d) +g(b) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictSubtypingCannotAddKeysToAClosedSupertype] +from typing import TypedDict +A = TypedDict('A', {'x': int}, closed=True) +B = TypedDict('B', {'x': int, 'y': int}, closed=True) +C = TypedDict('C', {'x': int}) +D = TypedDict('D', {'x': int, 'y': int}) +def f(x: A) -> None: pass +def g(x: C) -> None: pass +b: B +d: D +f(b) # E: Argument 1 to "f" has incompatible type "B"; expected "A" +g(d) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictSubtypingCanDropOptionalReadonlyKeysInAClosedSubtype] +from typing import ReadOnly, TypedDict +# Optional, readonly, closed: permitted +A = TypedDict('A', {'x': int, 'y': ReadOnly[int]}, total=False, closed=True) +B = TypedDict('B', {'x': int}, total=False, closed=True) +b: B +def f_a(x: A) -> None: pass +f_a(b) + +# Optional, readonly, open: not permitted +C = TypedDict('C', {'x': int, 'y': ReadOnly[int]}, total=False) +D = TypedDict('D', {'x': int}, total=False) +d: D +def f_c(x: C) -> None: pass +f_c(d) # E: Argument 1 to "f_c" has incompatible type "D"; expected "C" + +# Optional, mutable, closed: not permitted +E = TypedDict('E', {'x': int, 'y': int}, total=False, closed=True) +F = TypedDict('F', {'x': int}, total=False, closed=True) +f: F +def f_e(x: E) -> None: pass +f_e(f) # E: Argument 1 to "f_e" has incompatible type "F"; expected "E" + +# Required, readonly, closed: not permitted +G = TypedDict('G', {'x': int, 'y': ReadOnly[int]}, closed=True) +H = TypedDict('H', {'x': int}, closed=True) +h: H +def f_g(x: G) -> None: pass +f_g(h) # E: Argument 1 to "f_g" has incompatible type "H"; expected "G" + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testTypedDictFinalAndClassVar] from typing import TypedDict, Final, ClassVar From 90cb21f7c6f17b8e28e41dd7b3a376679c818776 Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Fri, 24 Apr 2026 09:33:54 +0100 Subject: [PATCH 13/24] Implement closed support in subclassing Propagate closed from subclasses. Verify that a subclass of a closed TypedDict does not add keys, nor override the closed status. --- mypy/semanal_typeddict.py | 58 ++++++++++++++++--- test-data/unit/check-typeddict.test | 88 +++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 8 deletions(-) diff --git a/mypy/semanal_typeddict.py b/mypy/semanal_typeddict.py index 2d33cb679c9b4..75f0ee1aa8fe3 100644 --- a/mypy/semanal_typeddict.py +++ b/mypy/semanal_typeddict.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Collection from typing import Final, NamedTuple from mypy import errorcodes as codes, message_registry @@ -108,7 +109,7 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N if isinstance(defn.analyzed, TypedDictExpr): existing_info = defn.analyzed.info - is_closed: bool = False + is_closed: bool | None = None if "closed" in defn.keywords: is_closed = require_bool_literal_argument( self.api, defn.keywords["closed"], "closed", False @@ -133,7 +134,7 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N field_types, required_keys, readonly_keys, - is_closed, + is_closed or False, defn.line, existing_info, ) @@ -181,9 +182,8 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N new_field_sources, new_statements = self.analyze_typeddict_classdef_fields(defn) if new_field_sources is None: return True, None # Defer - # TODO: Implement closure analysis - field_types, required_keys, readonly_keys, any_placeholders = ( - self.resolve_field_inheritance(bases_info, new_field_sources, defn) + field_types, required_keys, readonly_keys, is_closed, any_placeholders = ( + self.resolve_field_inheritance(bases_info, new_field_sources, is_closed, defn) ) info = self.build_typeddict_typeinfo( defn.name, @@ -326,13 +326,36 @@ def verify_field_compatibility( ctx, ) + def verify_field_against_closed_bases( + self, + field_name: str, + closed_bases: Collection[tuple[TypeInfo, Collection[str]]], + primary_source_base_name: str | None, + ctx: Context, + ) -> None: + for closed_base_type, closed_base_fields in closed_bases: + if field_name in closed_base_fields: + continue + + if primary_source_base_name: + self.fail( + f'Cannot extend closed base class "{closed_base_type.name}" with field "{field_name}" from base class "{primary_source_base_name}"', + ctx, + ) + else: + self.fail( + f'Cannot extend closed base class "{closed_base_type.name}" with new field "{field_name}"', + ctx, + ) + def resolve_field_inheritance( self, bases: list[tuple[TypeInfo, dict[str, Type]]], child_field_sources: dict[str, FieldSource], + child_is_closed: bool | None, ctx: Context, - ) -> tuple[dict[str, Type], set[str], set[str], bool]: - """Determine field types, requiredness, and readonlyness. + ) -> tuple[dict[str, Type], set[str], set[str], bool, bool]: + """Determine field types, requiredness, readonlyness, and closedness. Additionally returns if any placeholders were seen, as they will prevent full analysis, but may not result in placeholders in the final type. @@ -342,6 +365,18 @@ def resolve_field_inheritance( required_keys: set[str] = set() readonly_keys: set[str] = set() any_placeholders = False + closed_bases = [ + (base_info, base_fields.keys()) + for (base_info, base_fields) in bases + if base_info.typeddict_type and base_info.typeddict_type.is_closed + ] + + if child_is_closed is False and closed_bases: + for base_info, _ in closed_bases: + self.fail( + f'Open TypedDict class cannot subclass closed TypedDict class "{base_info.name}"', + ctx, + ) for field_name, sources in field_sources.items(): primary_source = self.primary_source(sources) @@ -379,8 +414,15 @@ def resolve_field_inheritance( primary_source.base_name, primary_source.child_field_ctx or ctx, ) + self.verify_field_against_closed_bases( + field_name, + closed_bases, + primary_source.base_name, + primary_source.child_field_ctx or ctx, + ) - return field_types, required_keys, readonly_keys, any_placeholders + is_closed = bool(closed_bases) if child_is_closed is None else child_is_closed + return field_types, required_keys, readonly_keys, is_closed, any_placeholders def _parse_typeddict_base(self, base: Expression, ctx: Context) -> TypeInfo: if isinstance(base, RefExpr): diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 464b2591da335..1148a356349ef 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -5128,6 +5128,94 @@ B = TypedDict('B', {'x': int}, total=True, total=False) # E: Repeated keyword a [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testTypedDictSubclassingClosed] +from typing import TypedDict +class D(TypedDict, closed=True): + x: int + y: float +d: D +reveal_type(d) # N: Revealed type is "TypedDict('__main__.D', {'x': builtins.int, 'y': builtins.float}, closed=True)" + +class D1(D): pass +d1: D1 +reveal_type(d1) # N: Revealed type is "TypedDict('__main__.D1', {'x': builtins.int, 'y': builtins.float}, closed=True)" + +class D2(D, closed=True): pass +d2: D2 +reveal_type(d2) # N: Revealed type is "TypedDict('__main__.D2', {'x': builtins.int, 'y': builtins.float}, closed=True)" + +class D3(D, closed=False): pass # E: Open TypedDict class cannot subclass closed TypedDict class "D" +d3: D3 +reveal_type(d3) # N: Revealed type is "TypedDict('__main__.D3', {'x': builtins.int, 'y': builtins.float})" + +class D4(D): + z: int # E: Cannot extend closed base class "D" with new field "z" +d4: D4 +reveal_type(d4) # N: Revealed type is "TypedDict('__main__.D4', {'x': builtins.int, 'y': builtins.float, 'z': builtins.int}, closed=True)" + +class D5(D): + x: int +d5: D5 +reveal_type(d5) # N: Revealed type is "TypedDict('__main__.D5', {'x': builtins.int, 'y': builtins.float}, closed=True)" + +class D6(D): + x: float # E: Definition of field "x" incompatible with base class "D" + y: int # E: Definition of field "y" incompatible with base class "D" +d6: D6 +reveal_type(d6) # N: Revealed type is "TypedDict('__main__.D6', {'x': builtins.float, 'y': builtins.int}, closed=True)" + +class D7(TypedDict, closed=True): + x: int + z: float +class D8(D7, D): # E: Cannot extend closed base class "D7" with field "y" from base class "D" \ + # E: Cannot extend closed base class "D" with field "z" from base class "D7" + pass +d8: D8 +reveal_type(d8) # N: Revealed type is "TypedDict('__main__.D8', {'x': builtins.int, 'y': builtins.float, 'z': builtins.float}, closed=True)" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictSubclassingOpenAndClosed] +from typing import TypedDict +class B1(TypedDict, closed=True): + x: int + y: float +class B2(TypedDict): + x: int +class D1(B1, B2): pass +class D2(B2, B1): pass +d1: D1 +d2: D2 +reveal_type(d1) # N: Revealed type is "TypedDict('__main__.D1', {'x': builtins.int, 'y': builtins.float}, closed=True)" +reveal_type(d2) # N: Revealed type is "TypedDict('__main__.D2', {'x': builtins.int, 'y': builtins.float}, closed=True)" +class B3(TypedDict): + z: int +class D3(B1, B3): pass # E: Cannot extend closed base class "B1" with field "z" from base class "B3" +class D4(B3, B1): pass # E: Cannot extend closed base class "B1" with field "z" from base class "B3" +d3: D3 +d4: D4 +reveal_type(d3) # N: Revealed type is "TypedDict('__main__.D3', {'z': builtins.int, 'x': builtins.int, 'y': builtins.float}, closed=True)" +reveal_type(d4) # N: Revealed type is "TypedDict('__main__.D4', {'x': builtins.int, 'y': builtins.float, 'z': builtins.int}, closed=True)" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testCanSubclassClosedTypedDictWithForwardDeclarations] +from typing import TypedDict, final + +class D1(TypedDict, closed=True): + forward_declared: "ForwardDeclared" + +class D2(D1): + pass + +class ForwardDeclared: pass + +d2: D2 +reveal_type(d2) # N: Revealed type is "TypedDict('__main__.D2', {'forward_declared': __main__.ForwardDeclared}, closed=True)" + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + [case testTypedDictSubtypingClosedMustRemainClosed] from typing import TypedDict A = TypedDict('A', {'x': int}, closed=True) From 410652538d8c627479a75f55444946deb848783d Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Tue, 28 Apr 2026 11:28:37 +0100 Subject: [PATCH 14/24] Implement closed support in join logic Close the join of two closed TypedDicts, and treat a missing key in a closed TypedDict as a `NotRequired[Never]` rather than the `ReadOnly[NotRequired[object]]` of an open TypedDict. --- mypy/join.py | 88 +++++++++++++++++++++-------- test-data/unit/check-typeddict.test | 21 +++++++ 2 files changed, 86 insertions(+), 23 deletions(-) diff --git a/mypy/join.py b/mypy/join.py index db225fadebb1b..f716a5198b3e5 100644 --- a/mypy/join.py +++ b/mypy/join.py @@ -610,36 +610,78 @@ def visit_tuple_type(self, t: TupleType) -> ProperType: else: return join_types(self.s, mypy.typeops.tuple_fallback(t)) + def resolve_typeddict_item( + self, + item_name: str, + s: TypedDictType, + s_item_type: Type | None, + t: TypedDictType, + t_item_type: Type | None, + ) -> tuple[Type | None, bool, bool]: + """Return the type, requiredness, and readonlyness of a join item. + + If the type is None, the item should be omitted from the join keys. + """ + if s_item_type is None or t_item_type is None: + is_required = False + if (s_item_type is None and not s.is_closed) or ( + t_item_type is None and not t.is_closed + ): + # Key is implicitly NotRequired[ReadOnly[object]] in one type and + # (object join T) == object. Omitting it in the join leaves it implicitly + # of object type. + join_type = None + else: + # Key is implicitly NotRequired[Never] in one type and (Never join T) == T + join_type = s_item_type if t_item_type is None else t_item_type + # One type is missing the setitem overload for this key, so the join supertype + # must also omit it + is_readonly = True + else: + s_required = item_name in s.required_keys + t_required = item_name in t.required_keys + is_required = s_required and t_required + + if not is_equivalent(s_item_type, t_item_type): + join_type = join_types(s_item_type, t_item_type) + is_readonly = True + else: + join_type = s_item_type + s_readonly = item_name in s.readonly_keys + t_readonly = item_name in t.readonly_keys + if s_required != t_required: + # As one of the input types marks the key as not required, it must + # be not required in the join supertype. However, as the other input + # type does not have a delitem overload for the key, the delitem + # overload must be omitted in the join supertype too. This can only + # be done by marking the key as readonly. + is_readonly = True + else: + # If either type has no setitem overload for this key, + # then the join supertype must also omit it + is_readonly = s_readonly or t_readonly + + return join_type, is_required, is_readonly + def visit_typeddict_type(self, t: TypedDictType) -> ProperType: if isinstance(self.s, TypedDictType): items = {} + required_keys = set() readonly_keys = set() - for item_name, s_item_type, t_item_type in self.s.zip(t): - if is_equivalent(s_item_type, t_item_type): - items[item_name] = s_item_type - s_required = item_name in self.s.required_keys - t_required = item_name in t.required_keys - s_readonly = item_name in self.s.readonly_keys - t_readonly = item_name in t.readonly_keys - if s_readonly or t_readonly: - # If either type has no setitem overload for this key, - # then the join supertype must also omit it - readonly_keys.add(item_name) - elif s_required != t_required: - # As one of the input types marks the key as not required, - # it must be not required in the join supertype. The other - # input type does not have a delitem overload for the key, - # so it must be omitted in the join supertype too, by marking - # the key as readonly. + for item_name, s_item_type, t_item_type in self.s.zipall(t): + item_type, is_required, is_readonly = self.resolve_typeddict_item( + item_name, self.s, s_item_type, t, t_item_type + ) + if item_type is not None: + items[item_name] = item_type + if is_required: + required_keys.add(item_name) + if is_readonly: readonly_keys.add(item_name) - else: - items[item_name] = join_types(s_item_type, t_item_type) - readonly_keys.add(item_name) fallback = self.s.create_anonymous_fallback() - required_keys = t.required_keys & self.s.required_keys - # TODO: Implement closure analysis - return TypedDictType(items, required_keys, readonly_keys, False, fallback) + is_closed = self.s.is_closed and t.is_closed + return TypedDictType(items, required_keys, readonly_keys, is_closed, fallback) elif isinstance(self.s, Instance): return join_types(self.s, t.fallback) else: diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 1148a356349ef..aa1b9faa72751 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -5283,6 +5283,27 @@ f_g(h) # E: Argument 1 to "f_g" has incompatible type "H"; expected "G" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testJoinOfClosedTypedDict] +from typing import TypedDict, TypeVar +A = TypedDict('A', {'x': int}, closed=True) +B = TypedDict('B', {'y': int}) +C = TypedDict('C', {'y': int}, closed=True) +D = TypedDict('D', {'x': int, 'y': int}, closed=True) +T = TypeVar('T') +def j(x: T, y: T) -> T: return x +a: A +b: B +c: C +d: D +reveal_type(j(a, b)) # N: Revealed type is "TypedDict({'y'?=: builtins.int})" +reveal_type(j(b, a)) # N: Revealed type is "TypedDict({'y'?=: builtins.int})" +reveal_type(j(a, c)) # N: Revealed type is "TypedDict({'x'?=: builtins.int, 'y'?=: builtins.int}, closed=True)" +reveal_type(j(c, a)) # N: Revealed type is "TypedDict({'y'?=: builtins.int, 'x'?=: builtins.int}, closed=True)" +reveal_type(j(a, d)) # N: Revealed type is "TypedDict({'x': builtins.int, 'y'?=: builtins.int}, closed=True)" +reveal_type(j(d, a)) # N: Revealed type is "TypedDict({'x': builtins.int, 'y'?=: builtins.int}, closed=True)" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testTypedDictFinalAndClassVar] from typing import TypedDict, Final, ClassVar From 6cc0d63a642bb6ebc75c3a61785aeac88cb90f74 Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Wed, 29 Apr 2026 09:19:42 +0100 Subject: [PATCH 15/24] Implement closed support in meet logic The meet of a closed TypedDict and another TypedDict must be closed. The implicit type of a missing key in a closed TypedDict is `ReadOnly[NotRequired[Never]]`. --- mypy/meet.py | 23 ++++++- test-data/unit/check-typeddict.test | 101 ++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/mypy/meet.py b/mypy/meet.py index 057f7d4027cbd..c19f169e2e870 100644 --- a/mypy/meet.py +++ b/mypy/meet.py @@ -1114,7 +1114,7 @@ def resolve_typeddict_item_type( returned type will be None; the overall meet type should be UninhabitedType. """ - # A missing key is implicitly ReadOnly[NotRequired[object]] + # A missing key is implicitly ReadOnly[NotRequired[...]] l_mutable = s_item_type is not None and name not in s.readonly_keys r_mutable = t_item_type is not None and name not in t.readonly_keys l_required = name in s.required_keys @@ -1122,6 +1122,14 @@ def resolve_typeddict_item_type( is_readonly = not l_mutable and not r_mutable + # Simplify the logic by using Never instead of None for missing keys + # in closed TypedDicts. Ideally we'd use builtins.object instead of + # None in open TypedDicts, but that is hard to get. + if s_item_type is None and s.is_closed: + s_item_type = UninhabitedType() + if t_item_type is None and t.is_closed: + t_item_type = UninhabitedType() + if t_item_type is None: assert s_item_type is not None meet_type = s_item_type @@ -1156,6 +1164,7 @@ def resolve_typeddict_item_type( def visit_typeddict_type(self, t: TypedDictType) -> ProperType: if isinstance(self.s, TypedDictType): + is_closed = self.s.is_closed or t.is_closed items: dict[str, Type] = {} readonly_keys: set[str] = set() for name, s_item_type, t_item_type in self.s.zipall(t): @@ -1166,14 +1175,22 @@ def visit_typeddict_type(self, t: TypedDictType) -> ProperType: if meet_type is None: return self.default(self.s) + if ( + is_closed + and is_readonly + and isinstance(get_proper_type(meet_type), UninhabitedType) + ): + # Simplify emitted type by omitting redundant ReadOnly[Never] keys + # from closed TypedDicts + continue + items[name] = meet_type if is_readonly: readonly_keys.add(name) fallback = self.s.create_anonymous_fallback() required_keys = self.s.required_keys | t.required_keys - # TODO: implement closure analysis - return TypedDictType(items, required_keys, readonly_keys, False, fallback) + return TypedDictType(items, required_keys, readonly_keys, is_closed, fallback) elif isinstance(self.s, Instance) and is_subtype(t, self.s): return t else: diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index aa1b9faa72751..f2af345ff398a 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -5304,6 +5304,107 @@ reveal_type(j(d, a)) # N: Revealed type is "TypedDict({'x': builtins.int, 'y'?=: [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testMeetOfClosedTypedDictsWithMatchingRequiredKeysIsNotAnonymous] +from typing import TypedDict, TypeVar, Callable +X = TypedDict('X', {'x': int, 'y': int}, closed=True) +Y = TypedDict('Y', {'x': int, 'y': int}, closed=True) +Z = TypedDict('Z', {'x': int, 'y': int}) +T = TypeVar('T') +def meet(x: Callable[[T, T], None]) -> T: pass +def fXY(x: X, y: Y) -> None: pass +def fYX(x: Y, y: X) -> None: pass +def fXZ(x: X, y: Z) -> None: pass +def fZX(x: Z, y: X) -> None: pass +reveal_type(meet(fXY)) # N: Revealed type is "TypedDict('__main__.X', {'x': builtins.int, 'y': builtins.int}, closed=True)" +reveal_type(meet(fYX)) # N: Revealed type is "TypedDict('__main__.Y', {'x': builtins.int, 'y': builtins.int}, closed=True)" +reveal_type(meet(fXZ)) # N: Revealed type is "TypedDict('__main__.X', {'x': builtins.int, 'y': builtins.int}, closed=True)" +reveal_type(meet(fZX)) # N: Revealed type is "TypedDict('__main__.X', {'x': builtins.int, 'y': builtins.int}, closed=True)" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testMeetOfClosedTypedDictsWithDifferentKeys] +from typing import TypedDict, TypeVar, Callable, ReadOnly +T = TypeVar('T') +def meet(x: Callable[[T, T], None]) -> T: pass +A = TypedDict('A', {'a': int}, closed=True) +B = TypedDict('B', {'b': int}, closed=True) +def fAB(x: A, y: B) -> None: pass +if int(): + reveal_type(meet(fAB)) # N: Revealed type is "Never" +C = TypedDict('C', {'c': int}, closed=True, total=False) +D = TypedDict('D', {'d': int}, closed=True, total=False) +def fCD(x: C, y: D) -> None: pass +if int(): + reveal_type(meet(fCD)) # N: Revealed type is "Never" +E = TypedDict('E', {'e': ReadOnly[int]}, closed=True) +F = TypedDict('F', {'f': ReadOnly[int]}, closed=True) +def fEF(x: E, y: F) -> None: pass +if int(): + reveal_type(meet(fEF)) # N: Revealed type is "Never" +G = TypedDict('G', {'g': ReadOnly[int]}, closed=True, total=False) +H = TypedDict('H', {'h': ReadOnly[int]}, closed=True, total=False) +def fAH(x: A, y: H) -> None: pass +if int(): + reveal_type(meet(fAH)) # N: Revealed type is "Never" +def fCH(x: C, y: H) -> None: pass +if int(): + reveal_type(meet(fCH)) # N: Revealed type is "Never" +def fEH(x: E, y: H) -> None: pass +if int(): + reveal_type(meet(fEH)) # N: Revealed type is "Never" +def fGH(x: G, y: H) -> None: pass +if int(): + reveal_type(meet(fGH)) # N: Revealed type is "TypedDict({}, closed=True)" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testMeetOfOpenAndClosedTypedDictsExtraKeyInClosed] +from typing import TypedDict, TypeVar, Callable, ReadOnly +T = TypeVar('T') +def meet(x: Callable[[T, T], None]) -> T: pass +Ax = TypedDict('Ax', {'a': ReadOnly[float]}) +AyB = TypedDict('AyB', {'a': ReadOnly[int], 'b': int}, closed=True) +def fAxAyB(x: Ax, y: AyB) -> None: pass +def fAyBAx(x: AyB, y: Ax) -> None: pass +reveal_type(meet(fAxAyB)) # N: Revealed type is "TypedDict({'a'=: builtins.int, 'b': builtins.int}, closed=True)" +reveal_type(meet(fAyBAx)) # N: Revealed type is "TypedDict({'a'=: builtins.int, 'b': builtins.int}, closed=True)" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testMeetOfOpenAndClosedTypedDictsExtraKeyInOpen] +from typing import TypedDict, TypeVar, Callable, ReadOnly +T = TypeVar('T') +def meet(x: Callable[[T, T], None]) -> T: pass +C = TypedDict('C', {'a': ReadOnly[float]}, closed=True) +D1 = TypedDict('D1', {'a': ReadOnly[int], 'b': int}) +D2 = TypedDict('D2', {'a': ReadOnly[int], 'b': ReadOnly[int]}) +D3 = TypedDict('D3', {'a': ReadOnly[int], 'b': int}, total=False) +D4 = TypedDict('D4', {'a': ReadOnly[int], 'b': ReadOnly[int]}, total=False) +def fCD1(x: C, y: D1) -> None: pass +def fD1C(x: D1, y: C) -> None: pass +def fCD2(x: C, y: D2) -> None: pass +def fD2C(x: D2, y: C) -> None: pass +def fCD3(x: C, y: D3) -> None: pass +def fD3C(x: D3, y: C) -> None: pass +def fCD4(x: C, y: D4) -> None: pass +def fD4C(x: D4, y: C) -> None: pass +if int(): + reveal_type(meet(fCD1)) # N: Revealed type is "Never" +if int(): + reveal_type(meet(fD1C)) # N: Revealed type is "Never" +if int(): + reveal_type(meet(fCD2)) # N: Revealed type is "Never" +if int(): + reveal_type(meet(fD2C)) # N: Revealed type is "Never" +if int(): + reveal_type(meet(fCD3)) # N: Revealed type is "Never" +if int(): + reveal_type(meet(fD3C)) # N: Revealed type is "Never" +reveal_type(meet(fCD4)) # N: Revealed type is "TypedDict({'a'=: builtins.int}, closed=True)" +reveal_type(meet(fD4C)) # N: Revealed type is "TypedDict({'a'=: builtins.int}, closed=True)" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testTypedDictFinalAndClassVar] from typing import TypedDict, Final, ClassVar From 5bfcc43c3fcf1333b9153d2284cb76e4a5b29e7e Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Thu, 30 Apr 2026 09:20:01 +0100 Subject: [PATCH 16/24] Implement closed support in narrowing Narrowing a union already treats final TypedDicts as closed; use the same logic if the closed keyword is used. --- mypy/checker.py | 1 + test-data/unit/check-typeddict.test | 159 ++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) diff --git a/mypy/checker.py b/mypy/checker.py index dac9252b8f394..2d157bb21d333 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6416,6 +6416,7 @@ def conditional_types_for_iterable( ) ) or ( key not in possible_iterable_type.items + and not possible_iterable_type.is_closed and not possible_iterable_type.is_final ): if_types.append(possible_iterable_type) diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index f2af345ff398a..4aa39d0638533 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -5405,6 +5405,165 @@ reveal_type(meet(fD4C)) # N: Revealed type is "TypedDict({'a'=: builtins.int}, [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testOperatorContainsNarrowsTypedDicts_unionWithList_closed] +from __future__ import annotations +from typing import assert_type, TypedDict, Union + +class D(TypedDict, closed=True): + foo: int + +d_or_list: D | list[str] + +if 'foo' in d_or_list: + assert_type(d_or_list, Union[D, list[str]]) +elif 'bar' in d_or_list: + assert_type(d_or_list, list[str]) +else: + assert_type(d_or_list, list[str]) + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testOperatorContainsNarrowsTypedDicts_total_closed] +from __future__ import annotations +from typing import assert_type, Literal, TypedDict, TypeVar, Union + +class D1(TypedDict, closed=True): + foo: int + +class D2(TypedDict, closed=True): + bar: int + +d: D1 | D2 + +if 'foo' in d: + assert_type(d, D1) +else: + assert_type(d, D2) + +foo_or_bar: Literal['foo', 'bar'] +if foo_or_bar in d: + assert_type(d, Union[D1, D2]) +else: + assert_type(d, Union[D1, D2]) + +foo_or_invalid: Literal['foo', 'invalid'] +if foo_or_invalid in d: + assert_type(d, D1) + # won't narrow 'foo_or_invalid' + assert_type(foo_or_invalid, Literal['foo', 'invalid']) +else: + assert_type(d, Union[D1, D2]) + # won't narrow 'foo_or_invalid' + assert_type(foo_or_invalid, Literal['foo', 'invalid']) + +TD = TypeVar('TD', D1, D2) + +def f(arg: TD) -> None: + value: int + if 'foo' in arg: + assert_type(arg['foo'], int) + else: + assert_type(arg['bar'], int) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testOperatorContainsNarrowsTypedDicts_closed] +# flags: --warn-unreachable +from __future__ import annotations +from typing import assert_type, TypedDict, Union + +class DClosed(TypedDict, closed=True): + foo: int + +class DNotClosed(TypedDict): + bar: int + +d_not_closed: DNotClosed + +if 'bar' in d_not_closed: + assert_type(d_not_closed, DNotClosed) +else: + spam = 'ham' # E: Statement is unreachable + +if 'spam' in d_not_closed: + assert_type(d_not_closed, DNotClosed) +else: + assert_type(d_not_closed, DNotClosed) + +d_closed: DClosed + +if 'spam' in d_closed: + spam = 'ham' # E: Statement is unreachable +else: + assert_type(d_closed, DClosed) + +d_union: DClosed | DNotClosed + +if 'foo' in d_union: + assert_type(d_union, Union[DClosed, DNotClosed]) +else: + assert_type(d_union, DNotClosed) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testOperatorContainsNarrowsTypedDicts_partialThroughTotalFalse_closed] +from __future__ import annotations +from typing import assert_type, Literal, TypedDict, Union + +class DTotal(TypedDict, closed=True): + required_key: int + +class DNotTotal(TypedDict, total=False, closed=True): + optional_key: int + +d: DTotal | DNotTotal + +if 'required_key' in d: + assert_type(d, DTotal) +else: + assert_type(d, DNotTotal) + +if 'optional_key' in d: + assert_type(d, DNotTotal) +else: + assert_type(d, Union[DTotal, DNotTotal]) + +key: Literal['optional_key', 'required_key'] +if key in d: + assert_type(d, Union[DTotal, DNotTotal]) +else: + assert_type(d, Union[DTotal, DNotTotal]) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testOperatorContainsNarrowsTypedDicts_partialThroughNotRequired_closed] +from __future__ import annotations +from typing import assert_type, TypedDict, Union +from typing_extensions import Required, NotRequired + +class D1(TypedDict, closed=True): + required_key: Required[int] + optional_key: NotRequired[int] + +class D2(TypedDict, closed=True): + abc: int + xyz: int + +d: D1 | D2 + +if 'required_key' in d: + assert_type(d, D1) +else: + assert_type(d, D2) + +if 'optional_key' in d: + assert_type(d, D1) +else: + assert_type(d, Union[D1, D2]) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + [case testTypedDictFinalAndClassVar] from typing import TypedDict, Final, ClassVar From d7449df716f461c63b9158d615a1a59be423db5a Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Thu, 30 Apr 2026 11:23:50 +0100 Subject: [PATCH 17/24] Implement TypeVar support in narrowing If a TypeVar with a TypedDict upper bound is encountered while narrowing, narrow based on the upper bound. --- mypy/checker.py | 25 +++++++++++++------------ test-data/unit/check-typeddict.test | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 2d157bb21d333..312c3ccb83e21 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6391,7 +6391,8 @@ def conditional_types_for_iterable( ) -> tuple[Type, Type]: """ Narrows the type of `iterable_type` based on the type of `item_type`. - For now, we only support narrowing unions of TypedDicts based on left operand being literal string(s). + For now, we only support narrowing unions of TypedDicts, and TypeVars with TypedDict + bounds, based on left operand being literal string(s). """ if_types: list[Type] = [] else_types: list[Type] = [] @@ -6405,20 +6406,20 @@ def conditional_types_for_iterable( item_str_literals = try_getting_str_literals_from_type(item_type) for possible_iterable_type in possible_iterable_types: - if item_str_literals and isinstance(possible_iterable_type, TypedDictType): + bound = ( + get_proper_type(possible_iterable_type.upper_bound) + if isinstance(possible_iterable_type, TypeVarType) + else possible_iterable_type + ) + + if item_str_literals and isinstance(bound, TypedDictType): for key in item_str_literals: - if key in possible_iterable_type.required_keys: + if key in bound.required_keys: if_types.append(possible_iterable_type) elif ( - key in possible_iterable_type.items - and not isinstance( - get_proper_type(possible_iterable_type.items[key]), UninhabitedType - ) - ) or ( - key not in possible_iterable_type.items - and not possible_iterable_type.is_closed - and not possible_iterable_type.is_final - ): + key in bound.items + and not isinstance(get_proper_type(bound.items[key]), UninhabitedType) + ) or (key not in bound.items and not bound.is_closed and not bound.is_final): if_types.append(possible_iterable_type) else_types.append(possible_iterable_type) else: diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 4aa39d0638533..4a452c5451dae 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -5564,6 +5564,21 @@ else: [builtins fixtures/dict.pyi] [typing fixtures/typing-full.pyi] +[case testOperatorContainsNarrowsTypeVarWithClosedTypedDictBound] +from typing import TypedDict, TypeVar +from typing_extensions import Never, assert_type + +T = TypeVar('T', bound='D') + +class D(TypedDict, closed=True): + a: int + +def func(t: T): + if "b" in t: + assert_type(t, Never) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testTypedDictFinalAndClassVar] from typing import TypedDict, Final, ClassVar From 262170497e8ec4dde945c82964ce6006956d01a1 Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Thu, 30 Apr 2026 09:48:19 +0100 Subject: [PATCH 18/24] Implement closed support in astdiff --- mypy/server/astdiff.py | 2 +- test-data/unit/diff.test | 18 +++++++++++++ test-data/unit/fine-grained.test | 43 ++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/mypy/server/astdiff.py b/mypy/server/astdiff.py index 9bbc3077ec512..663c4e7171815 100644 --- a/mypy/server/astdiff.py +++ b/mypy/server/astdiff.py @@ -497,7 +497,7 @@ def visit_typeddict_type(self, typ: TypedDictType) -> SnapshotItem: items = tuple((key, snapshot_type(item_type)) for key, item_type in typ.items.items()) required = tuple(sorted(typ.required_keys)) readonly = tuple(sorted(typ.readonly_keys)) - return ("TypedDictType", items, required, readonly) + return ("TypedDictType", items, required, readonly, typ.is_closed) def visit_literal_type(self, typ: LiteralType) -> SnapshotItem: return ("LiteralType", snapshot_type(typ.fallback), typ.value) diff --git a/test-data/unit/diff.test b/test-data/unit/diff.test index 1f1987183fe46..c4bbf7d3f2283 100644 --- a/test-data/unit/diff.test +++ b/test-data/unit/diff.test @@ -676,6 +676,24 @@ p = Point(dict(x=42, y=1337)) __main__.Point __main__.p +[case testTypedDict5] +from typing import TypedDict +class Point(TypedDict): + x: int + y: int +p = Point(dict(x=42, y=1337)) +[file next.py] +from typing import TypedDict +class Point(TypedDict, closed=True): + x: int + y: int +p = Point(dict(x=42, y=1337)) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] +[out] +__main__.Point +__main__.p + [case testTypeAliasSimple] A = int B = int diff --git a/test-data/unit/fine-grained.test b/test-data/unit/fine-grained.test index 3e21a77db3431..417a7d817c71d 100644 --- a/test-data/unit/fine-grained.test +++ b/test-data/unit/fine-grained.test @@ -3745,6 +3745,49 @@ b.py:4: error: ReadOnly TypedDict key "y" TypedDict is mutated == b.py:3: error: ReadOnly TypedDict key "x" TypedDict is mutated +[case testTypedDictUpdateClosed] +import b +[file a.py] +from typing import TypedDict, Union +class A(TypedDict): + a: int +class B(TypedDict, closed=True): + b: int +C = Union[A, B] +[file a.py.2] +from typing import TypedDict, Union +class A(TypedDict, closed=True): + a: int +class B(TypedDict): + b: int +C = Union[A, B] +[file a.py.3] +from typing import TypedDict, Union +class A(TypedDict, closed=True): + a: int +class B(TypedDict, closed=True): + b: int +C = Union[A, B] +[file b.py] +from a import C +def foo(x: C) -> int: + if "b" in x: + return x["b"] + else: + return x["a"] +def bar(x: C) -> int: + if "a" in x: + return x["a"] + else: + return x["b"] +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] +[out] +b.py:4: error: TypedDict "A" has no key "b" +== +b.py:9: error: TypedDict "B" has no key "a" +== + [case testBasicAliasUpdate] import b [file a.py] From 34a13de10d281e74b32de0bcf5464c4bdc068fda Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Thu, 30 Apr 2026 09:55:54 +0100 Subject: [PATCH 19/24] Implement closed support in stubgen --- mypy/stubgen.py | 15 ++++++++++++--- test-data/unit/stubgen.test | 18 ++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/mypy/stubgen.py b/mypy/stubgen.py index 9c682ba4b8201..7c5815b1a5926 100755 --- a/mypy/stubgen.py +++ b/mypy/stubgen.py @@ -1094,9 +1094,13 @@ def process_typeddict(self, lvalue: NameExpr, rvalue: CallExpr) -> None: if not isinstance(rvalue.args[0], StrExpr): self.annotate_as_incomplete(lvalue) return + if len(rvalue.args) > 2 and rvalue.arg_kinds[2] != ARG_NAMED: + self.annotate_as_incomplete(lvalue) + return items: list[tuple[str, Expression]] = [] total: Expression | None = None + closed: Expression | None = None if len(rvalue.args) > 1 and rvalue.arg_kinds[1] == ARG_POS: if not isinstance(rvalue.args[1], DictExpr): self.annotate_as_incomplete(lvalue) @@ -1106,11 +1110,14 @@ def process_typeddict(self, lvalue: NameExpr, rvalue: CallExpr) -> None: self.annotate_as_incomplete(lvalue) return items.append((attr_name.value, attr_type)) - if len(rvalue.args) > 2: - if rvalue.arg_kinds[2] != ARG_NAMED or rvalue.arg_names[2] != "total": + for arg_name, arg in zip(rvalue.arg_names[2:], rvalue.args[2:]): + if arg_name == "total": + total = arg + elif arg_name == "closed": + closed = arg + else: self.annotate_as_incomplete(lvalue) return - total = rvalue.args[2] else: for arg_name, arg in zip(rvalue.arg_names[1:], rvalue.args[1:]): if not isinstance(arg_name, str): @@ -1130,6 +1137,8 @@ def process_typeddict(self, lvalue: NameExpr, rvalue: CallExpr) -> None: # TODO: Add support for generic TypedDicts. Requires `Generic` as base class. if total is not None: bases += f", total={total.accept(p)}" + if closed is not None: + bases += f", closed={closed.accept(p)}" class_def = f"{self._indent}class {lvalue.name}({bases}):" if len(items) == 0: self.add(f"{class_def} ...\n") diff --git a/test-data/unit/stubgen.test b/test-data/unit/stubgen.test index 161f14e8aea77..0c8b74ecf29a1 100644 --- a/test-data/unit/stubgen.test +++ b/test-data/unit/stubgen.test @@ -3822,16 +3822,26 @@ def f(x: str | None) -> None: ... [case testTypeddict] import typing, x -X = typing.TypedDict('X', {'a': int, 'b': str}) -Y = typing.TypedDict('X', {'a': int, 'b': str}, total=False) +D1 = typing.TypedDict('D1', {'a': int, 'b': str}) +D2 = typing.TypedDict('D2', {'a': int, 'b': str}, total=False) +D3 = typing.TypedDict('D3', {'a': int, 'b': str}, closed=True) +D4 = typing.TypedDict('D4', {'a': int, 'b': str}, closed=True, total=False) [out] from typing_extensions import TypedDict -class X(TypedDict): +class D1(TypedDict): a: int b: str -class Y(TypedDict, total=False): +class D2(TypedDict, total=False): + a: int + b: str + +class D3(TypedDict, closed=True): + a: int + b: str + +class D4(TypedDict, total=False, closed=True): a: int b: str From 87ede99dd2d44d33d4403094cdf677bb9c24d41a Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Thu, 30 Apr 2026 10:33:12 +0100 Subject: [PATCH 20/24] Implement closed support in serialization --- mypy/types.py | 8 ++++---- test-data/unit/check-serialize.test | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/mypy/types.py b/mypy/types.py index 8da7a313ee0ec..98158070a9511 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -3023,17 +3023,17 @@ def serialize(self) -> JsonDict: "required_keys": sorted(self.required_keys), "readonly_keys": sorted(self.readonly_keys), "fallback": self.fallback.serialize(), + "is_closed": self.is_closed, } @classmethod def deserialize(cls, data: JsonDict) -> TypedDictType: assert data[".class"] == "TypedDictType" - # TODO: round-trip is_closed return TypedDictType( {n: deserialize_type(t) for (n, t) in data["items"]}, set(data["required_keys"]), set(data["readonly_keys"]), - False, + bool(data["is_closed"]), Instance.deserialize(data["fallback"]), ) @@ -3043,18 +3043,18 @@ def write(self, data: WriteBuffer) -> None: write_type_map(data, self.items) write_str_list(data, sorted(self.required_keys)) write_str_list(data, sorted(self.readonly_keys)) + write_bool(data, self.is_closed) write_tag(data, END_TAG) @classmethod def read(cls, data: ReadBuffer) -> TypedDictType: assert read_tag(data) == INSTANCE fallback = Instance.read(data) - # TODO: round-trip is_closed ret = TypedDictType( read_type_map(data), set(read_str_list(data)), set(read_str_list(data)), - False, + read_bool(data), fallback, ) assert read_tag(data) == END_TAG diff --git a/test-data/unit/check-serialize.test b/test-data/unit/check-serialize.test index 7a257bc017c36..315d1e3d2cd3c 100644 --- a/test-data/unit/check-serialize.test +++ b/test-data/unit/check-serialize.test @@ -1088,6 +1088,20 @@ main:2: note: Revealed type is "TypedDict('m.D', {'x'?: builtins.int, 'y'?: buil [out2] main:2: note: Revealed type is "TypedDict('m.D', {'x'?: builtins.int, 'y'?: builtins.str})" +[case testSerializeClosedTotalTypedDict] +from m import d +reveal_type(d) +[file m.py] +from typing import TypedDict +D = TypedDict('D', {'x': int, 'y': str}, closed=True) +d: D +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] +[out1] +main:2: note: Revealed type is "TypedDict('m.D', {'x': builtins.int, 'y': builtins.str}, closed=True)" +[out2] +main:2: note: Revealed type is "TypedDict('m.D', {'x': builtins.int, 'y': builtins.str}, closed=True)" + -- -- Modules -- From bbdd8274e3bcf014133c3b37e790cf5641973b7f Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Thu, 30 Apr 2026 12:23:41 +0100 Subject: [PATCH 21/24] Implement closed support in TypeAnalyser Propagate closed when analysing a TypedDictType with a fallback. This ensures subtype checks work correctly for a TypeVar with a closed TypedDict upper bound. --- mypy/typeanal.py | 5 +++-- test-data/unit/check-typeddict.test | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index d121f2c6c0ec5..7c29fc27cf466 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -1341,6 +1341,7 @@ def visit_typeddict_type(self, t: TypedDictType) -> Type: " must be enabled with --enable-incomplete-feature=InlineTypedDict", t, ) + is_closed = False required_keys = req_keys fallback = self.named_type("typing._TypedDict") for typ in t.extra_items_from: @@ -1363,9 +1364,9 @@ def visit_typeddict_type(self, t: TypedDictType) -> Type: readonly_keys = t.readonly_keys required_keys = t.required_keys fallback = t.fallback - # TODO: Implement closure analysis + is_closed = t.is_closed return TypedDictType( - items, required_keys, readonly_keys, False, fallback, t.line, t.column + items, required_keys, readonly_keys, is_closed, fallback, t.line, t.column ) def visit_raw_expression_type(self, t: RawExpressionType) -> Type: diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 4a452c5451dae..f478a3a9aa275 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -5283,6 +5283,23 @@ f_g(h) # E: Argument 1 to "f_g" has incompatible type "H"; expected "G" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testTypedDictSubtypingClosedTypeVarBound] +from typing import TypedDict, TypeVar +A = TypedDict('A', {'x': int}, closed=True) +B = TypedDict('B', {'x': int}) +TA = TypeVar('TA', bound=A) +TB = TypeVar('TB', bound=B) +def fA(t: TA) -> TA: return t +def fB(t: TB) -> TB: return t +a: A +b: B +fA(a) +fA(b) # E: Value of type variable "TA" of "fA" cannot be "B" +fB(a) +fB(b) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testJoinOfClosedTypedDict] from typing import TypedDict, TypeVar A = TypedDict('A', {'x': int}, closed=True) From c1ff8b37c0ee6cdc29ff9182820e2511a2269f76 Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Thu, 30 Apr 2026 13:28:54 +0100 Subject: [PATCH 22/24] Implement closed support in unpacking Unpacking a TypedDict with an undeclared key to a TypedDict with that key declared as not required is acceptable if the former is closed. Unpacking an open TypedDict into a closed TypedDict is never safe. --- mypy/checkexpr.py | 47 ++++++++++++++++-------- test-data/unit/check-typeddict.test | 55 +++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 15 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index c9296bd5e875d..2eaebcb15cc11 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -820,8 +820,8 @@ def validate_typeddict_kwargs( result = defaultdict(list) # Keys that are guaranteed to be present no matter what (e.g. for all items of a union) always_present_keys = set() - # Indicates latest encountered ** unpack among items. - last_star_found = None + # Indicates latest encountered ** unpack of a non-closed type among items. + last_open_star_found = None for item_name_expr, item_arg in kwargs: if item_name_expr: @@ -845,22 +845,30 @@ def validate_typeddict_kwargs( result[literal_value] = [item_arg] always_present_keys.add(literal_value) else: - last_star_found = item_arg - if not self.validate_star_typeddict_item( + is_valid, is_open = self.validate_star_typeddict_item( item_arg, callee, result, always_present_keys - ): + ) + if not is_valid: return None - if self.chk.options.extra_checks and last_star_found is not None: + if is_open: + last_open_star_found = item_arg + if self.chk.options.extra_checks and last_open_star_found is not None: + if callee.is_closed: + self.chk.fail( + "Cannot unpack item that may contain extra keys into a closed TypedDict", + last_open_star_found, + code=codes.TYPEDDICT_ITEM, + ) absent_keys = [] for key in callee.items: if key not in callee.required_keys and key not in result: absent_keys.append(key) if absent_keys: - # Having an optional key not explicitly declared by a ** unpacked + # Having an optional key not explicitly declared by a ** unpacked open # TypedDict is unsafe, it may be an (incompatible) subtype at runtime. # TODO: catch the cases where a declared key is overridden by a subsequent # ** item without it (and not again overridden with complete ** item). - self.msg.non_required_keys_absent_with_star(absent_keys, last_star_found) + self.msg.non_required_keys_absent_with_star(absent_keys, last_open_star_found) return result, always_present_keys def validate_star_typeddict_item( @@ -869,14 +877,18 @@ def validate_star_typeddict_item( callee: TypedDictType, result: dict[str, list[Expression]], always_present_keys: set[str], - ) -> bool: + ) -> tuple[bool, bool]: """Update keys/expressions from a ** expression in TypedDict constructor. - Note `result` and `always_present_keys` are updated in place. Return true if the - expression `item_arg` may valid in `callee` TypedDict context. + Note `result` and `always_present_keys` are updated in place. + + First tuple item returned is true if the expression `item_arg` may valid + in `callee` TypedDict context. Second tuple item returned is true if the + expression may contain other keys not explicitly declared. """ inferred = get_proper_type(self.accept(item_arg, type_context=callee)) - possible_tds = [] + any_fallback = False + possible_tds: list[TypedDictType] = [] if isinstance(inferred, TypedDictType): possible_tds = [inferred] elif isinstance(inferred, UnionType): @@ -885,10 +897,14 @@ def validate_star_typeddict_item( possible_tds.append(item) elif not self.valid_unpack_fallback_item(item): self.msg.unsupported_target_for_star_typeddict(item, item_arg) - return False + return False, True + else: + any_fallback = True elif not self.valid_unpack_fallback_item(inferred): self.msg.unsupported_target_for_star_typeddict(inferred, item_arg) - return False + return False, True + else: + any_fallback = True all_keys: set[str] = set() for td in possible_tds: all_keys |= td.items.keys() @@ -917,7 +933,8 @@ def validate_star_typeddict_item( # If this key is not required at least in some item of a union # it may not shadow previous item, so we need to type check both. result[key].append(arg) - return True + all_closed = all(t.is_closed for t in possible_tds) + return True, any_fallback or not all_closed def valid_unpack_fallback_item(self, typ: ProperType) -> bool: if isinstance(typ, AnyType): diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index f478a3a9aa275..2dbb89211e426 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -5596,6 +5596,61 @@ def func(t: T): [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testTypedDictUnpackFromClosedMissingKey] +# flags: --extra-checks +from typing import TypedDict +from typing_extensions import Never, NotRequired +D1 = TypedDict("D1", {"a": int, "b": str}, closed=True) +D2 = TypedDict("D2", {"a": int, "b": str, "c": int}) +D3 = TypedDict("D3", {"a": int, "b": str, "c": NotRequired[int]}) +d1: D1 +d2: D2 = {**d1} # E: Missing key "c" for TypedDict "D2" +d3: D3 = {**d1} +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictUnpackIntoClosed] +# flags: --extra-checks +from typing import Any, Mapping, TypedDict, Union +from typing_extensions import Never, NotRequired +D1 = TypedDict("D1", {"a": int, "b": str}, closed=True) +D2 = TypedDict("D2", {"a": int, "b": str}) +D3 = TypedDict("D3", {"c": int, "d": str}, closed=True) +D4 = TypedDict("D4", {"c": int, "d": str}) +D5 = TypedDict("D5", {"a": int, "b": str, "c": int, "d": str}, closed=True) +d1: D1 +d2: D2 +d3: D3 +d4: D4 +d5: D5 +m: Mapping[Any, Any] +u34: Union[D3, D4] +u35: Union[D3, D5] +u5m: Union[D5, Mapping[Any, Any]] +d5 = {**d1, **d3} +d5 = { + **d1, + **d4, # E: Cannot unpack item that may contain extra keys into a closed TypedDict +} +d5 = { + **d2, # E: Cannot unpack item that may contain extra keys into a closed TypedDict + **d3, +} +d5 = { + **d1, + **u34, # E: Cannot unpack item that may contain extra keys into a closed TypedDict +} +d5 = {**d1, **u35} +d5 = { + **m, # E: Cannot unpack item that may contain extra keys into a closed TypedDict + **d5, +} +d5 = { + **u5m, # E: Cannot unpack item that may contain extra keys into a closed TypedDict +} +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testTypedDictFinalAndClassVar] from typing import TypedDict, Final, ClassVar From ca5a13f75b20688c7276e962fc7df3852548ccfb Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Fri, 1 May 2026 09:35:38 +0100 Subject: [PATCH 23/24] Implement closed support in expandtype Close the kwargs TypedDict returned by ParamSpec where possible. Use an empty, closed TypedDict instead of a `dict[str, Never]` for functions that do not accept kwargs. --- mypy/expandtype.py | 10 +++++----- .../unit/check-parameter-specification.test | 16 ++++++++-------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/mypy/expandtype.py b/mypy/expandtype.py index ba2b90e4a2533..5870c45ad8d64 100644 --- a/mypy/expandtype.py +++ b/mypy/expandtype.py @@ -344,7 +344,7 @@ def _possible_callable_kwargs(cls, repl: Parameters, dict_type: Instance) -> Pro return dict_type kwargs = {} required_names = set() - extra_items: Type = UninhabitedType() + extra_items: Type | None = None for kind, name, type in zip(repl.arg_kinds, repl.arg_names, repl.arg_types): if kind == ArgKind.ARG_NAMED and name is not None: kwargs[name] = type @@ -354,11 +354,11 @@ def _possible_callable_kwargs(cls, repl: Parameters, dict_type: Instance) -> Pro extra_items = type elif not kind.is_star() and name is not None: kwargs[name] = type - if not kwargs: + if not kwargs and extra_items is not None: return Instance(dict_type.type, [dict_type.args[0], extra_items]) - # TODO: when PEP 728 is implemented, pass extra_items below. - # TODO: implement closure analysis - return TypedDictType(kwargs, required_names, set(), False, fallback=dict_type) + # TODO: when PEP 728 `extra_items` is implemented, pass extra_items below. + is_closed = extra_items is None + return TypedDictType(kwargs, required_names, set(), is_closed, fallback=dict_type) def visit_type_var_tuple(self, t: TypeVarTupleType) -> Type: # Sometimes solver may need to expand a type variable with (a copy of) itself diff --git a/test-data/unit/check-parameter-specification.test b/test-data/unit/check-parameter-specification.test index 970ba45d0e8e2..d1e928441a9ec 100644 --- a/test-data/unit/check-parameter-specification.test +++ b/test-data/unit/check-parameter-specification.test @@ -2742,16 +2742,16 @@ def f8(x: int, /, **kwargs: str) -> int: def f9(x: int, **kwargs: Unpack[Opt]) -> int: return 0 -reveal_type(Sneaky(f1).kwargs) # N: Revealed type is "builtins.dict[builtins.str, Never]" -reveal_type(Sneaky(f2, 1).kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x'?: builtins.int})" -reveal_type(Sneaky(f3, 1).kwargs) # N: Revealed type is "builtins.dict[builtins.str, Never]" -reveal_type(Sneaky(f4, x=1).kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x': builtins.int})" -reveal_type(Sneaky(f5, 1).kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x'?: builtins.int, 'y'?: builtins.int})" -reveal_type(Sneaky(f5, 1, 2).kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x'?: builtins.int, 'y'?: builtins.int})" +reveal_type(Sneaky(f1).kwargs) # N: Revealed type is "TypedDict('builtins.dict', {}, closed=True)" +reveal_type(Sneaky(f2, 1).kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x'?: builtins.int}, closed=True)" +reveal_type(Sneaky(f3, 1).kwargs) # N: Revealed type is "TypedDict('builtins.dict', {}, closed=True)" +reveal_type(Sneaky(f4, x=1).kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x': builtins.int}, closed=True)" +reveal_type(Sneaky(f5, 1).kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x'?: builtins.int, 'y'?: builtins.int}, closed=True)" +reveal_type(Sneaky(f5, 1, 2).kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x'?: builtins.int, 'y'?: builtins.int}, closed=True)" reveal_type(Sneaky(f6, x=1).kwargs) # N: Revealed type is "builtins.dict[builtins.str, builtins.int]" reveal_type(Sneaky(f6, x=1, y=2).kwargs) # N: Revealed type is "builtins.dict[builtins.str, builtins.int]" reveal_type(Sneaky(f7, 1, y='').kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x'?: builtins.int})" reveal_type(Sneaky(f8, 1, y='').kwargs) # N: Revealed type is "builtins.dict[builtins.str, builtins.str]" -reveal_type(Sneaky(f9, 1, y=0).kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x'?: builtins.int, 'y': builtins.int, 'z'?: builtins.str})" -reveal_type(Sneaky(f9, 1, y=0, z='').kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x'?: builtins.int, 'y': builtins.int, 'z'?: builtins.str})" +reveal_type(Sneaky(f9, 1, y=0).kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x'?: builtins.int, 'y': builtins.int, 'z'?: builtins.str}, closed=True)" +reveal_type(Sneaky(f9, 1, y=0, z='').kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x'?: builtins.int, 'y': builtins.int, 'z'?: builtins.str}, closed=True)" [builtins fixtures/paramspec.pyi] From 4d3a7b0412f60012529c4c090e4583541bd9f785 Mon Sep 17 00:00:00 2001 From: Alice Purcell Date: Fri, 1 May 2026 15:49:34 +0100 Subject: [PATCH 24/24] Implement closed support in get method --- mypy/plugins/default.py | 7 +++--- test-data/unit/check-typeddict.test | 37 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/mypy/plugins/default.py b/mypy/plugins/default.py index 8023397836124..a7d1c9e4426ab 100644 --- a/mypy/plugins/default.py +++ b/mypy/plugins/default.py @@ -293,9 +293,10 @@ def typed_dict_get_callback(ctx: MethodContext) -> Type: for key in keys: value_type: Type | None = ctx.type.items.get(key) if value_type is None: - return ctx.default_return_type - - if key in ctx.type.required_keys: + if not ctx.type.is_closed: + return ctx.default_return_type + output_types.append(default_type) + elif key in ctx.type.required_keys: output_types.append(value_type) else: # HACK to deal with get(key, {}) diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 2dbb89211e426..911693eab5c38 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -5422,6 +5422,43 @@ reveal_type(meet(fD4C)) # N: Revealed type is "TypedDict({'a'=: builtins.int}, [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testTypedDictGetMethodClosed] +from typing import TypedDict, Literal +class Unrelated: pass +D = TypedDict('D', {'x': int, 'y': str}, closed=True) +d: D +u: Unrelated +x: Literal['x'] +y: Literal['y'] +z: Literal['z'] +x_or_y: Literal['x', 'y'] +x_or_z: Literal['x', 'z'] +x_or_y_or_z: Literal['x', 'y', 'z'] + +# test with literal expression +reveal_type(d.get('x')) # N: Revealed type is "builtins.int" +reveal_type(d.get('y')) # N: Revealed type is "builtins.str" +reveal_type(d.get('z')) # N: Revealed type is "None" +reveal_type(d.get('z', u)) # N: Revealed type is "__main__.Unrelated" + +# test with literal type / union of literal types with implicit default +reveal_type(d.get(x)) # N: Revealed type is "builtins.int" +reveal_type(d.get(y)) # N: Revealed type is "builtins.str" +reveal_type(d.get(z)) # N: Revealed type is "None" +reveal_type(d.get(x_or_y)) # N: Revealed type is "builtins.int | builtins.str" +reveal_type(d.get(x_or_z)) # N: Revealed type is "builtins.int | None" +reveal_type(d.get(x_or_y_or_z)) # N: Revealed type is "builtins.int | builtins.str | None" + +# test with literal type / union of literal types with explicit default +reveal_type(d.get(x, u)) # N: Revealed type is "builtins.int" +reveal_type(d.get(y, u)) # N: Revealed type is "builtins.str" +reveal_type(d.get(z, u)) # N: Revealed type is "__main__.Unrelated" +reveal_type(d.get(x_or_y, u)) # N: Revealed type is "builtins.int | builtins.str" +reveal_type(d.get(x_or_z, u)) # N: Revealed type is "builtins.int | __main__.Unrelated" +reveal_type(d.get(x_or_y_or_z, u)) # N: Revealed type is "builtins.int | builtins.str | __main__.Unrelated" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testOperatorContainsNarrowsTypedDicts_unionWithList_closed] from __future__ import annotations from typing import assert_type, TypedDict, Union