Skip to content

Conversation

@oconnor663
Copy link
Contributor

@astral-sh-bot
Copy link

astral-sh-bot bot commented Nov 15, 2025

Diagnostic diff on typing conformance tests

Changes were detected when running ty on typing conformance tests
--- old-output.txt	2025-11-19 21:06:40.475992259 +0000
+++ new-output.txt	2025-11-19 21:06:43.942997809 +0000
@@ -954,11 +954,15 @@
 typeddicts_extra_items.py:285:43: error[invalid-key] Unknown key "language" for TypedDict `ExtraMovie`: Unknown key "language"
 typeddicts_extra_items.py:293:44: error[invalid-key] Unknown key "year" for TypedDict `ClosedMovie`: Unknown key "year"
 typeddicts_extra_items.py:299:54: error[invalid-key] Unknown key "summary" for TypedDict `MovieExtraStr`: Unknown key "summary"
+typeddicts_extra_items.py:300:34: error[invalid-assignment] Object of type `MovieExtraStr` is not assignable to `Mapping[str, str]`
 typeddicts_extra_items.py:302:54: error[invalid-key] Unknown key "year" for TypedDict `MovieExtraInt`: Unknown key "year"
+typeddicts_extra_items.py:303:34: error[invalid-assignment] Object of type `MovieExtraInt` is not assignable to `Mapping[str, int]`
+typeddicts_extra_items.py:304:44: error[invalid-assignment] Object of type `MovieExtraInt` is not assignable to `Mapping[str, int | str]`
 typeddicts_extra_items.py:310:5: error[type-assertion-failure] Type `list[tuple[str, int | str]]` does not match asserted type `list[tuple[str, object]]`
 typeddicts_extra_items.py:311:5: error[type-assertion-failure] Type `list[int | str]` does not match asserted type `list[object]`
-typeddicts_extra_items.py:327:5: error[unresolved-attribute] Object of type `IntDict` has no attribute `clear`
+typeddicts_extra_items.py:326:25: error[invalid-assignment] Object of type `IntDict` is not assignable to `dict[str, int]`
 typeddicts_extra_items.py:329:52: error[invalid-key] Unknown key "bar" for TypedDict `IntDictWithNum` - did you mean "num"?
+typeddicts_extra_items.py:330:32: error[invalid-assignment] Object of type `IntDictWithNum` is not assignable to `dict[str, int]`
 typeddicts_extra_items.py:337:1: error[unresolved-attribute] Object of type `IntDictWithNum` has no attribute `clear`
 typeddicts_extra_items.py:339:1: error[type-assertion-failure] Type `tuple[str, int]` does not match asserted type `Unknown`
 typeddicts_extra_items.py:339:13: error[unresolved-attribute] Object of type `IntDictWithNum` has no attribute `popitem`
@@ -980,12 +984,26 @@
 typeddicts_readonly.py:51:4: error[invalid-assignment] Cannot assign to key "year" on TypedDict `Movie1`: key is marked read-only
 typeddicts_readonly.py:60:4: error[invalid-assignment] Cannot assign to key "title" on TypedDict `Movie2`: key is marked read-only
 typeddicts_readonly.py:61:4: error[invalid-assignment] Cannot assign to key "year" on TypedDict `Movie2`: key is marked read-only
+typeddicts_readonly_consistency.py:37:14: error[invalid-assignment] Object of type `A1` is not assignable to `B1`
+typeddicts_readonly_consistency.py:38:14: error[invalid-assignment] Object of type `C1` is not assignable to `B1`
+typeddicts_readonly_consistency.py:40:14: error[invalid-assignment] Object of type `A1` is not assignable to `C1`
+typeddicts_readonly_consistency.py:81:14: error[invalid-assignment] Object of type `A2` is not assignable to `B2`
+typeddicts_readonly_consistency.py:82:14: error[invalid-assignment] Object of type `C2` is not assignable to `B2`
+typeddicts_readonly_consistency.py:84:14: error[invalid-assignment] Object of type `A2` is not assignable to `C2`
+typeddicts_readonly_consistency.py:85:14: error[invalid-assignment] Object of type `B2` is not assignable to `C2`
 typeddicts_readonly_inheritance.py:36:4: error[invalid-assignment] Cannot assign to key "name" on TypedDict `Album2`: key is marked read-only
 typeddicts_readonly_inheritance.py:65:19: error[missing-typed-dict-key] Missing required key 'name' in TypedDict `RequiredName` constructor
 typeddicts_readonly_inheritance.py:82:14: error[invalid-assignment] Invalid assignment to key "ident" with declared type `str` on TypedDict `User`: value of type `Literal[3]`
 typeddicts_readonly_inheritance.py:83:15: error[invalid-argument-type] Invalid argument to key "ident" with declared type `str` on TypedDict `User`: value of type `Literal[3]`
 typeddicts_readonly_inheritance.py:84:5: error[missing-typed-dict-key] Missing required key 'ident' in TypedDict `User` constructor
+typeddicts_type_consistency.py:21:10: error[invalid-assignment] Object of type `B1` is not assignable to `A1`
+typeddicts_type_consistency.py:38:10: error[invalid-assignment] Object of type `B2` is not assignable to `A2`
+typeddicts_type_consistency.py:65:6: error[invalid-assignment] Object of type `A3` is not assignable to `B3`
 typeddicts_type_consistency.py:69:21: error[invalid-key] Unknown key "y" for TypedDict `A3`: Unknown key "y"
+typeddicts_type_consistency.py:76:22: error[invalid-assignment] Object of type `B3` is not assignable to `dict[str, int]`
+typeddicts_type_consistency.py:77:25: error[invalid-assignment] Object of type `B3` is not assignable to `dict[str, object]`
+typeddicts_type_consistency.py:78:22: error[invalid-assignment] Object of type `B3` is not assignable to `dict[Any, Any]`
+typeddicts_type_consistency.py:82:25: error[invalid-assignment] Object of type `B3` is not assignable to `Mapping[str, int]`
 typeddicts_type_consistency.py:101:14: error[invalid-assignment] Object of type `Unknown | None` is not assignable to `str`
 typeddicts_type_consistency.py:126:56: error[invalid-argument-type] Invalid argument to key "inner_key" with declared type `str` on TypedDict `Inner1`: value of type `Literal[1]`
 typeddicts_usage.py:23:7: error[invalid-key] Unknown key "director" for TypedDict `Movie`: Unknown key "director"
@@ -993,5 +1011,5 @@
 typeddicts_usage.py:28:17: error[missing-typed-dict-key] Missing required key 'name' in TypedDict `Movie` constructor
 typeddicts_usage.py:28:18: error[invalid-key] Unknown key "title" for TypedDict `Movie`: Unknown key "title"
 typeddicts_usage.py:40:24: error[invalid-type-form] The special form `typing.TypedDict` is not allowed in type expressions. Did you mean to use a concrete TypedDict or `collections.abc.Mapping[str, object]` instead?
-Found 995 diagnostics
+Found 1013 diagnostics
 WARN A fatal error occurred while checking some files. Not all project files were analyzed. See the diagnostics list above for details.


class Person(TypedDict):
name: str
phone_number: str
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test wants to look at the diagnostics we print for TypedDicts in a union, but previously Person was a subtype of Animal, so this PR made the union disappear. Adding phone_number breaks the subtyping relationship, so the union remains.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually if we end up going with "don't generally simplify Unions of TypedDicts", I can put this test back the way it was.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better for the test to pass whichever way we go on that, so this change seems good to me :-)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I expect if we land more comprehensive cycle-panic avoidance, we might want to go back to simplifying typed dicts in unions.

@astral-sh-bot
Copy link

astral-sh-bot bot commented Nov 15, 2025

mypy_primer results

Changes were detected when running on open source projects
aioredis (https://github.com/aio-libs/aioredis)
+ aioredis/connection.py:1301:9: error[no-matching-overload] No overload of bound method `update` matches arguments
- Found 25 diagnostics
+ Found 26 diagnostics

kornia (https://github.com/kornia/kornia)
+ kornia/feature/adalam/core.py:399:96: error[no-matching-overload] No overload of bound method `__init__` matches arguments
- Found 766 diagnostics
+ Found 767 diagnostics

pytest (https://github.com/pytest-dev/pytest)
+ testing/typing_checks.py:47:25: error[invalid-argument-type] Argument to bound method `setitem` is incorrect: Expected `Mapping[Literal["x"], Literal[2]]`, found `Foo`
+ testing/typing_checks.py:48:25: error[invalid-argument-type] Argument to bound method `delitem` is incorrect: Expected `Mapping[Literal["y"], Unknown]`, found `Foo`
- Found 445 diagnostics
+ Found 447 diagnostics

poetry (https://github.com/python-poetry/poetry)
+ src/poetry/utils/authenticator.py:224:9: error[no-matching-overload] No overload of bound method `update` matches arguments
- Found 978 diagnostics
+ Found 979 diagnostics

freqtrade (https://github.com/freqtrade/freqtrade)
- freqtrade/rpc/telegram.py:445:77: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ freqtrade/rpc/telegram.py:543:46: error[invalid-argument-type] Argument to bound method `_format_entry_msg` is incorrect: Expected `RPCEntryMsg`, found `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 7 union elements`
+ freqtrade/rpc/telegram.py:546:45: error[invalid-argument-type] Argument to bound method `_format_exit_msg` is incorrect: Expected `RPCExitMsg`, found `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 7 union elements`
+ freqtrade/rpc/telegram.py:553:62: error[invalid-argument-type] Argument to bound method `_exchange_from_msg` is incorrect: Expected `RPCEntryMsg | RPCExitMsg | RPCExitCancelMsg | RPCCancelMsg`, found `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 7 union elements`
- Found 610 diagnostics
+ Found 612 diagnostics

meson (https://github.com/mesonbuild/meson)
+ mesonbuild/build.py:1274:42: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[Literal["link_args"], Unknown]`, found `BuildTargetKeywordArguments`
+ mesonbuild/build.py:1288:35: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[Literal["include_directories"], Unknown]`, found `BuildTargetKeywordArguments`
+ mesonbuild/build.py:1291:35: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[Literal["dependencies"], Unknown]`, found `BuildTargetKeywordArguments`
+ mesonbuild/build.py:2645:40: error[invalid-argument-type] Argument to bound method `process_vs_module_defs_kw` is incorrect: Expected `ExecutableKeywordArguments`, found `SharedLibraryKeywordArguments`
+ mesonbuild/interpreter/interpreter.py:1303:87: error[invalid-argument-type] Argument to function `machine_from_native_kwarg` is incorrect: Expected `dict[str, Any]`, found `FuncAddLanguages`
- mesonbuild/interpreter/interpreter.py:1874:16: error[invalid-return-type] Return type does not match returned value: expected `SharedModule`, found `SharedLibrary`
+ mesonbuild/interpreter/interpreter.py:1905:20: error[no-matching-overload] No overload of bound method `build_target` matches arguments
+ mesonbuild/interpreter/interpreter.py:1916:16: error[no-matching-overload] No overload of bound method `build_target` matches arguments
+ mesonbuild/interpreter/interpreter.py:2215:35: error[invalid-argument-type] Argument to bound method `add_test` is incorrect: Expected `dict[str, Any]`, found `FuncBenchmark`
+ mesonbuild/interpreter/interpreter.py:2222:35: error[invalid-argument-type] Argument to bound method `add_test` is incorrect: Expected `dict[str, Any]`, found `FuncTest`
+ mesonbuild/interpreter/interpreter.py:2253:37: error[invalid-argument-type] Argument to bound method `unpack_env_kwarg` is incorrect: Expected `EnvironmentVariables | dict[str, @Todo] | list[@Todo] | str`, found `BaseTest`
- mesonbuild/interpreter/interpreter.py:3452:16: error[invalid-key] Unknown key "dependencies" for TypedDict `SharedModule`: Unknown key "dependencies"
+ mesonbuild/interpreter/interpreter.py:3452:50: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[Literal["dependencies"], Unknown]`, found `Executable | StaticLibrary | SharedLibrary | Jar`
- mesonbuild/interpreter/interpreter.py:3454:33: error[invalid-assignment] Invalid assignment to key "extra_files" with declared type `list[File | str]` on TypedDict `SharedModule`: value of type `list[File]`
+ mesonbuild/interpreter/interpreter.py:3456:38: error[invalid-argument-type] Argument to bound method `__process_language_args` is incorrect: Expected `dict[str, list[File | str]]`, found `Executable | StaticLibrary | SharedLibrary | Jar`
- mesonbuild/interpreter/interpreter.py:3484:39: error[invalid-assignment] Invalid assignment to key "d_import_dirs" with declared type `list[str | IncludeDirs]` on TypedDict `SharedModule`: value of type `list[IncludeDirs]`
- mesonbuild/interpreter/mesonmain.py:385:55: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ mesonbuild/modules/python.py:158:45: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[Literal["dependencies"], Unknown]`, found `ExtensionModuleKw`
+ mesonbuild/modules/python.py:178:51: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[Literal["c_args"], Unknown]`, found `ExtensionModuleKw`
+ mesonbuild/modules/python.py:182:53: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[Literal["cpp_args"], Unknown]`, found `ExtensionModuleKw`
+ mesonbuild/modules/python.py:207:58: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[Literal["link_args"], Unknown]`, found `ExtensionModuleKw`
- mesonbuild/modules/python.py:231:16: error[invalid-return-type] Return type does not match returned value: expected `SharedModule`, found `Unknown | SharedLibrary`
+ mesonbuild/modules/simd.py:103:54: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[str, Unknown]`, found `StaticLibrary`
- Found 1722 diagnostics
+ Found 1733 diagnostics

discord.py (https://github.com/Rapptz/discord.py)
- discord/audit_logs.py:414:67: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/channel.py:561:87: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/channel.py:1654:87: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/channel.py:2010:87: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/channel.py:2169:87: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/channel.py:2815:87: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/channel.py:3234:57: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `PartialUser` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
+ discord/channel.py:3409:63: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `PartialUser` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
+ discord/components.py:1326:27: error[invalid-argument-type] Invalid argument to key "components" with declared type `list[ActionRow | TextComponent | MediaGalleryComponent | ... omitted 5 union elements]` on TypedDict `ContainerComponent`: value of type `list[ButtonComponent | SelectMenu | TextInput | ... omitted 11 union elements]`
- discord/components.py:1382:53: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/components.py:1464:34: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/components.py:1458:26: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `ActionRow`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1460:23: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `ButtonComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1462:26: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `TextInput`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1466:33: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `SectionComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1468:28: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `TextComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1470:35: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `ThumbnailComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1472:38: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `MediaGalleryComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1474:30: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `FileComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1476:35: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `SeparatorComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1478:26: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `ContainerComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1480:31: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `LabelComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1482:36: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `FileUploadComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
- discord/guild.py:653:73: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/guild.py:1961:13: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `ForumChannel | MediaChannel`, found `TextChannel | NewsChannel | VoiceChannel | ... omitted 5 union elements`
- discord/interactions.py:243:106: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/interactions.py:256:92: error[invalid-argument-type] Argument to bound method `format_map` is incorrect: Expected `_FormatMapMapping`, found `TextChannel | NewsChannel | VoiceChannel | ... omitted 7 union elements`
+ discord/member.py:319:45: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `User` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
- discord/message.py:535:68: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/message.py:791:77: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/message.py:2455:46: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `User` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
+ discord/message.py:2481:47: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `UserWithMember` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
- discord/onboarding.py:280:95: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/scheduled_event.py:150:63: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `User & ~AlwaysFalsy` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
- discord/state.py:676:52: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/state.py:912:47: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/state.py:1158:70: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/state.py:1121:32: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `User` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
+ discord/state.py:1388:36: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `User` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
- discord/state.py:1975:52: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/sticker.py:420:60: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `User & ~AlwaysFalsy` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
- discord/ui/view.py:1064:74: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/webhook/async_.py:751:44: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `User | PartialUser` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
- Found 478 diagnostics
+ Found 485 diagnostics

cwltool (https://github.com/common-workflow-language/cwltool)
+ cwltool/cwlprov/ro.py:357:21: error[no-matching-overload] No overload of bound method `update` matches arguments
- Found 151 diagnostics
+ Found 152 diagnostics

hydra-zen (https://github.com/mit-ll-responsible-ai/hydra-zen)
+ src/hydra_zen/structured_configs/_utils.py:257:22: error[invalid-key] TypedDict `AllConvert` can only be subscripted with a string literal key, got key of type `str`.
- Found 543 diagnostics
+ Found 544 diagnostics

openlibrary (https://github.com/internetarchive/openlibrary)
+ openlibrary/core/lists/model.py:431:13: error[invalid-assignment] Object of type `(Thing & ~str & ~Top[dict[Unknown, Unknown]]) | (AnnotatedSeed & ~str & ~Top[dict[Unknown, Unknown]])` is not assignable to attribute `value` of type `Thing | str`
+ openlibrary/core/lists/model.py:474:27: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `Thing | str | AnnotatedSeed`, found `str | ThingReferenceDict | AnnotatedSeedDict`
- Found 944 diagnostics
+ Found 946 diagnostics

prefect (https://github.com/PrefectHQ/prefect)
+ src/prefect/settings/profiles.py:103:56: error[invalid-argument-type] Argument to bound method `from_exception_data` is incorrect: Expected `list[InitErrorDetails]`, found `list[Unknown | ErrorDetails]`
- Found 3233 diagnostics
+ Found 3234 diagnostics

altair (https://github.com/vega/altair)
- altair/vegalite/v6/data.py:27:44: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ altair/vegalite/v6/data.py:25:36: error[invalid-argument-type] Argument to bound method `register` is incorrect: Expected `((...) -> typing.TypeVar) | None`, found `Overload[(data: None = ellipsis, prefix: str = ellipsis, extension: str = ellipsis, filename: str = ellipsis, urlpath: str = ellipsis) -> partial[Unknown], (data: dict[Any, Any] | @Todo | SupportsGeoInterface | DataFrameLike, prefix: str = ellipsis, extension: str = ellipsis, filename: str = ellipsis, urlpath: str = ellipsis) -> _ToFormatReturnUrlDict]`
- tests/vegalite/v6/test_api.py:564:17: error[invalid-key] Unknown key "condition" for TypedDict `_Value`: Unknown key "condition"
- tests/vegalite/v6/test_api.py:610:32: error[invalid-key] Unknown key "condition" for TypedDict `_Value`: Unknown key "condition"
- Found 992 diagnostics
+ Found 990 diagnostics

pandas-stubs (https://github.com/pandas-dev/pandas-stubs)
- tests/test_timefuncs.py:1352:11: error[type-assertion-failure] Type `Series[Timestamp]` does not match asserted type `Timestamp`
- tests/test_timefuncs.py:1353:11: error[type-assertion-failure] Type `Series[Timestamp]` does not match asserted type `Timestamp`
- Found 5966 diagnostics
+ Found 5964 diagnostics

zulip (https://github.com/zulip/zulip)
- zerver/tests/test_user_status.py:23:12: error[invalid-return-type] Return type does not match returned value: expected `UserInfoDict`, found `UserInfoDict | None`
+ zerver/views/auth.py:362:30: error[no-matching-overload] No overload of bound method `__init__` matches arguments

core (https://github.com/home-assistant/core)
- homeassistant/components/auth/login_flow.py:226:24: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- homeassistant/components/auth/mfa_setup_flow.py:157:24: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ homeassistant/components/auth/mfa_setup_flow.py:155:16: error[no-matching-overload] No overload of bound method `__init__` matches arguments
+ homeassistant/components/conversation/default_agent.py:274:33: error[invalid-key] Unknown key "changes" for TypedDict `_EventEntityRegistryUpdatedData_CreateRemove`: Unknown key "changes"
+ homeassistant/components/energy/data.py:367:29: error[invalid-assignment] Invalid assignment to key "energy_sources" with declared type `list[SourceType]` on TypedDict `EnergyPreferences`: value of type `list[SourceType] | list[DeviceConsumption]`
+ homeassistant/components/energy/data.py:367:29: error[invalid-assignment] Invalid assignment to key "device_consumption" with declared type `list[DeviceConsumption]` on TypedDict `EnergyPreferences`: value of type `list[SourceType] | list[DeviceConsumption]`
- homeassistant/components/evohome/storage.py:85:45: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ homeassistant/components/fronius/config_flow.py:119:53: error[no-matching-overload] No overload of bound method `__init__` matches arguments
+ homeassistant/components/geofency/device_tracker.py:71:9: error[invalid-assignment] Object of type `DeviceInfo` is not assignable to attribute `_attr_device_info` of type `None`
+ homeassistant/components/gpslogger/device_tracker.py:83:9: error[invalid-assignment] Object of type `DeviceInfo` is not assignable to attribute `_attr_device_info` of type `None`
- homeassistant/components/homekit/config_flow.py:502:25: error[invalid-assignment] Object of type `Any` is not assignable to `EntityFilterDict`
- homeassistant/components/homekit/config_flow.py:546:43: error[invalid-assignment] Object of type `Any` is not assignable to `EntityFilterDict`
- homeassistant/components/homekit/config_flow.py:649:43: error[invalid-assignment] Object of type `Any` is not assignable to `EntityFilterDict`
+ homeassistant/components/launch_library/__init__.py:56:9: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `(() -> Awaitable[dict[str, Any]]) | None`, found `def async_update() -> CoroutineType[Any, Any, LaunchLibraryData]`
+ homeassistant/components/mqtt/config_flow.py:3376:65: error[no-matching-overload] No overload of bound method `__init__` matches arguments
+ homeassistant/components/mqtt/config_flow.py:4589:13: error[no-matching-overload] No overload of bound method `update` matches arguments
+ homeassistant/components/mqtt/config_flow.py:4590:13: error[no-matching-overload] No overload of bound method `update` matches arguments
+ homeassistant/components/mqtt/config_flow.py:4626:9: error[no-matching-overload] No overload of bound method `update` matches arguments
+ homeassistant/components/mqtt/config_flow.py:4638:13: error[no-matching-overload] No overload of bound method `update` matches arguments
+ homeassistant/components/mqtt/config_flow.py:4639:13: error[no-matching-overload] No overload of bound method `update` matches arguments
+ homeassistant/components/mqtt/entity.py:382:17: error[no-matching-overload] No overload of bound method `update` matches arguments
+ homeassistant/components/mqtt/entity.py:383:17: error[no-matching-overload] No overload of bound method `update` matches arguments
+ homeassistant/components/traccar/device_tracker.py:129:9: error[invalid-assignment] Object of type `DeviceInfo` is not assignable to attribute `_attr_device_info` of type `None`
+ homeassistant/components/weather/__init__.py:703:46: error[no-matching-overload] No overload of bound method `__init__` matches arguments
+ homeassistant/config_entries.py:3911:33: error[invalid-argument-type] Method `__getitem__` of type `(Overload[(key: Literal["action"], /) -> Literal["create", "remove"], (key: Literal["entity_id"], /) -> str]) | (Overload[(key: Literal["action"], /) -> Literal["update"], (key: Literal["entity_id"], /) -> str, (key: Literal["changes"], /) -> dict[str, Any], (key: Literal["old_entity_id"], /) -> str])` cannot be called with key of type `Literal["changes"]` on object of type `EventEntityRegistryUpdatedData`
+ homeassistant/config_entries.py:3912:12: error[invalid-argument-type] Method `__getitem__` of type `(Overload[(key: Literal["action"], /) -> Literal["create", "remove"], (key: Literal["entity_id"], /) -> str]) | (Overload[(key: Literal["action"], /) -> Literal["update"], (key: Literal["entity_id"], /) -> str, (key: Literal["changes"], /) -> dict[str, Any], (key: Literal["old_entity_id"], /) -> str])` cannot be called with key of type `Literal["changes"]` on object of type `EventEntityRegistryUpdatedData`
+ homeassistant/helpers/device_registry.py:1456:13: error[invalid-argument-type] Argument to bound method `async_fire_internal` is incorrect: Expected `EventType[_EventDeviceRegistryUpdatedData_Remove] | str`, found `EventType[EventDeviceRegistryUpdatedData]`
+ homeassistant/helpers/device_registry.py:1861:36: error[invalid-argument-type] Method `__getitem__` of type `(Overload[(key: Literal["action"], /) -> Literal["create", "remove"], (key: Literal["entity_id"], /) -> str]) | (Overload[(key: Literal["action"], /) -> Literal["update"], (key: Literal["entity_id"], /) -> str, (key: Literal["changes"], /) -> dict[str, Any], (key: Literal["old_entity_id"], /) -> str])` cannot be called with key of type `Literal["changes"]` on object of type `EventEntityRegistryUpdatedData`
+ homeassistant/helpers/entity_registry.py:1079:13: error[invalid-argument-type] Argument to bound method `async_fire_internal` is incorrect: Expected `EventType[_EventEntityRegistryUpdatedData_CreateRemove] | str`, found `EventType[EventEntityRegistryUpdatedData]`
+ homeassistant/helpers/entity_registry.py:1126:13: error[invalid-argument-type] Argument to bound method `async_fire_internal` is incorrect: Expected `EventType[_EventEntityRegistryUpdatedData_CreateRemove] | str`, found `EventType[EventEntityRegistryUpdatedData]`
+ homeassistant/helpers/entity_registry.py:1378:43: error[invalid-argument-type] Argument to bound method `async_fire_internal` is incorrect: Expected `EventType[_EventEntityRegistryUpdatedData_Update] | str`, found `EventType[EventEntityRegistryUpdatedData]`
- Found 14489 diagnostics
+ Found 14508 diagnostics

pydantic (https://github.com/pydantic/pydantic)
+ pydantic/_internal/_discriminated_union.py:223:13: error[invalid-argument-type] Argument to function `tagged_union_schema` is incorrect: Expected `SimpleSerSchema | PlainSerializerFunctionSerSchema | WrapSerializerFunctionSerSchema | ... omitted 4 union elements`, found `SimpleSerSchema | PlainSerializerFunctionSerSchema | WrapSerializerFunctionSerSchema | ... omitted 6 union elements`
+ pydantic/_internal/_discriminated_union.py:270:83: error[invalid-argument-type] Argument to bound method `_is_discriminator_shared` is incorrect: Expected `TaggedUnionSchema`, found `InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements`
+ pydantic/_internal/_discriminated_union.py:342:70: error[invalid-argument-type] Argument to bound method `_infer_discriminator_values_for_model_choice` is incorrect: Expected `ModelFieldsSchema`, found `InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements`
+ pydantic/_internal/_discriminated_union.py:345:74: error[invalid-argument-type] Argument to bound method `_infer_discriminator_values_for_dataclass_choice` is incorrect: Expected `DataclassArgsSchema`, found `InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements`
+ pydantic/_internal/_discriminated_union.py:348:75: error[invalid-argument-type] Argument to bound method `_infer_discriminator_values_for_typed_dict_choice` is incorrect: Expected `TypedDictSchema`, found `InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements`
+ pydantic/_internal/_generate_schema.py:270:34: error[invalid-assignment] Invalid assignment to key "items_schema" with declared type `list[InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements]` on TypedDict `TupleSchema`: value of type `InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements`
+ pydantic/_internal/_generate_schema.py:2823:77: error[invalid-argument-type] Argument to function `_inlining_behavior` is incorrect: Expected `DefinitionReferenceSchema`, found `InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements`
+ pydantic/functional_validators.py:238:17: error[invalid-argument-type] Argument to function `with_info_plain_validator_function` is incorrect: Expected `SimpleSerSchema | PlainSerializerFunctionSerSchema | WrapSerializerFunctionSerSchema | ... omitted 4 union elements`, found `SimpleSerSchema | PlainSerializerFunctionSerSchema | WrapSerializerFunctionSerSchema | ... omitted 6 union elements`
+ pydantic/functional_validators.py:245:17: error[invalid-argument-type] Argument to function `no_info_plain_validator_function` is incorrect: Expected `SimpleSerSchema | PlainSerializerFunctionSerSchema | WrapSerializerFunctionSerSchema | ... omitted 4 union elements`, found `SimpleSerSchema | PlainSerializerFunctionSerSchema | WrapSerializerFunctionSerSchema | ... omitted 6 union elements`
+ pydantic/json_schema.py:563:49: error[invalid-argument-type] Argument to function `populate_defs` is incorrect: Expected `InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements`, found `InvalidSchema | AnySchema | NoneSchema | ... omitted 53 union elements`
+ pydantic/json_schema.py:585:41: error[invalid-argument-type] Argument to function `populate_defs` is incorrect: Expected `InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements`, found `InvalidSchema | AnySchema | NoneSchema | ... omitted 53 union elements`
- pydantic/json_schema.py:1808:30: error[invalid-assignment] Object of type `Any | None` is not assignable to `ConfigDict`
+ pydantic/json_schema.py:2857:16: error[invalid-return-type] Return type does not match returned value: expected `PlainSerializerFunctionSerSchema | None`, found `(SimpleSerSchema & ~AlwaysFalsy) | (PlainSerializerFunctionSerSchema & ~AlwaysFalsy) | (WrapSerializerFunctionSerSchema & ~AlwaysFalsy) | ... omitted 5 union elements`
+ pydantic/types.py:3135:13: error[invalid-argument-type] Argument to function `tagged_union_schema` is incorrect: Expected `SimpleSerSchema | PlainSerializerFunctionSerSchema | WrapSerializerFunctionSerSchema | ... omitted 4 union elements`, found `SimpleSerSchema | PlainSerializerFunctionSerSchema | WrapSerializerFunctionSerSchema | ... omitted 6 union elements`
- Found 4829 diagnostics
+ Found 4841 diagnostics

No memory usage changes detected ✅

Comment on lines +237 to +197
if self_item_field.is_required() {
// A required field can't be assigned to a not-required, mutable field
// in the target, because `del` is allowed on the target field.
return ConstraintSet::from(false);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rule was present in historical versions of the typing spec, but it's missing from the current version. It is mentioned and tested in the conformance suite. After this lands I'll open a PR to the typing docs upstream.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@AlexWaygood AlexWaygood added the ty Multi-file analysis & type inference label Nov 15, 2025
@astral-sh-bot
Copy link

astral-sh-bot bot commented Nov 15, 2025

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-argument-type 94 0 1
no-matching-overload 23 0 0
unused-ignore-comment 0 23 0
invalid-assignment 7 6 1
invalid-return-type 1 3 1
invalid-key 0 4 0
type-assertion-failure 0 2 0
unsupported-operator 2 0 0
Total 127 38 3

Full report with detailed diff (timing results)

@oconnor663
Copy link
Contributor Author

It seems pretty common in the ecosystem report to want to pass a TypedDict where a dict is required (but you know it won't be modified), like as an argument to dict.update. That's kind of a bummer.

@carljm
Copy link
Contributor

carljm commented Nov 15, 2025

It seems pretty common in the ecosystem report to want to pass a TypedDict where a dict is required (but you know it won't be modified), like as an argument to dict.update. That's kind of a bummer.

If there are cases like this showing up a lot in the ecosystem, can we just verify quick that we aren't being more strict than other type checkers? If not, then these are just true positives (at least as far as the specified type system is concerned) in the ecosystem that we are catching, great. If so, maybe other type checkers are implementing some kind of pragmatic compromise that we should at least consider.

Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!! I haven't looked through the whole PR yet, but spotted one significant thing

@AlexWaygood
Copy link
Member

Diagnostic diff on typing conformance tests

It looks like there are a few new false positives on the typing conformance suite file typeddicts_extra_items.py -- places where we're not meant to emit diagnostic, but we now do:

I guess this is just because we don't support extra_items at all yet? So maybe this is just all covered by the "PEP 728 support" bullet point in astral-sh/ty#154.

Other than the lines listed above, all other new diagnostics on the conformance suite look like true positives to me -- great job!

@AlexWaygood
Copy link
Member

AlexWaygood commented Nov 16, 2025

Unfortunately it looks like there is a huge slowdown on the pydantic benchmark on this branch, which is causing it to timeout in CI currently. On my macbook locally, the benchmark completes in 2.161s on main, but 1.017m (I assume m here stands for minutes!) on this branch.

I suggest tackling some of the correctness issues I pointed out in #21467 (comment) and #21467 (comment) before looking at this too much. There's always a chance that fixing one/both of those will "miraculously" fix the regression 😆

@oconnor663
Copy link
Contributor Author

It seems pretty common in the ecosystem report to want to pass a TypedDict where a dict is required (but you know it won't be modified), like as an argument to dict.update. That's kind of a bummer.

If there are cases like this showing up a lot in the ecosystem, can we just verify quick that we aren't being more strict than other type checkers? If not, then these are just true positives (at least as far as the specified type system is concerned) in the ecosystem that we are catching, great. If so, maybe other type checkers are implementing some kind of pragmatic compromise that we should at least consider.

Ah, this turned out to be exactly the issue that @AlexWaygood caught above: I was being too strict and making TypedDicts assignable only to Mapping[str, object], rather than to anything that Mapping[str, object] is assignable to. Alex's patch fixes this.

@oconnor663
Copy link
Contributor Author

It looks like there are a few new false positives on the typing conformance suite file typeddicts_extra_items.py -- places where we're not meant to emit diagnostic, but we now do...I guess this is just because we don't support extra_items at all yet?

Yes I think all of the new errors-that-shouldn't-be-there are cases where extra_items makes more assignments to dict legal. It might not be too hard to add extra_items support as part of this (or even to add a blanket "if you use extra_items we assume all assignments are legal" workaround), but then again since it's a Python 3.15 feature I'm leaning towards just accepting the false positives for a little while for simplicity?

@oconnor663 oconnor663 force-pushed the jack/typedict_assignment branch from b2d6f35 to bc09e93 Compare November 17, 2025 19:31
@oconnor663
Copy link
Contributor Author

There's always a chance that fixing one/both of those will "miraculously" fix the regression

Alas, no. Digging into it.

@oconnor663
Copy link
Contributor Author

I don't have an answer yet, but an example of a specific file that's giving us trouble (which might be infecting a lot of other files, not sure) is pydantic-core/python/pydantic_core/core_schema.py. That file defines a bunch of "*Schema" classes that are all TypedDicts, and it seems like most of those have one or more fields of type CoreSchema, which is actually a large Union of all the "*Schema" classes. So there's a lot of recursion going on.

@AlexWaygood
Copy link
Member

Something you could try would be to add Salsa caching to Type::is_assignable_to. We already cache Type::is_redundant_with; you'd want to add the #[salsa::tracked] attribute to Type::is_assignable_to in the same way that Type::is_redundant_with already has.

The cost of doing this would be an increased memory usage, but that might be worth it to avoid a 30x execution time increase and benchmarks that no longer complete in CI... worth experimenting with, anyway.

@oconnor663 oconnor663 force-pushed the jack/typedict_assignment branch from bc09e93 to df183be Compare November 17, 2025 23:54
Comment on lines 180 to 184
KnownClass::Object.to_instance(db).when_assignable_to(
db,
target_item_field.declared_ty,
inferable,
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

slightly more efficient, and less verbose:

Suggested change
KnownClass::Object.to_instance(db).when_assignable_to(
db,
target_item_field.declared_ty,
inferable,
)
Type::object().when_assignable_to(
db,
target_item_field.declared_ty,
inferable,
)

also, is really correct that we should use when_assignable_to even if relation == TypeRelation::Subtyping? Should it be this instead?

Suggested change
KnownClass::Object.to_instance(db).when_assignable_to(
db,
target_item_field.declared_ty,
inferable,
)
Type::object().has_relation_to_impl(
db,
target_item_field.declared_ty,
inferable,
relation,
relation_visitor,
disjointness_visitor,
)

(no tests fail if I make that change, so it looks like we might be missing some test coverage here either way ;)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

weird: committing the first suggestion resolves the whole comment, even though the second suggestion is pending

Copy link
Contributor Author

@oconnor663 oconnor663 Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's an example of something I can do in Python that demands the "subtyping" relation, as opposed to the "assignability" relation? (Besides ty_extensions.is_subtype_of I guess.) I get that they're going to be the same for fully-static types, so I know part of the answer is going to involve Any or similar, but I'm afraid I've gotten this far without actually knowing what subtyping per se is for :-D

(Edit: I went ahead and asked this in chat.)

@oconnor663
Copy link
Contributor Author

Hmm, that last change fixed my "minimal repro" but not the actual Pydantic check. Time to minimize again?

@oconnor663
Copy link
Contributor Author

oconnor663 commented Nov 18, 2025

New minimal repro. Seems sensitive to the order of appearance of different types within the union too.

from typing import Union, TypedDict

class Foo1(TypedDict):
    x1: MyUnion

class Foo2(TypedDict):
    x2: MyUnion

class Foo3(Foo2):
    x3: MyUnion

class Foo4(TypedDict):
    x4: MyUnion

MyUnion = Foo1 | Foo2 | Foo3 | Foo4

Presumably some combinatorial explosion of calls that's dodging cycle detection? Digging into it...

@carljm
Copy link
Contributor

carljm commented Nov 19, 2025

Presumably some combinatorial explosion of calls that's dodging cycle detection? Digging into it...

If the error is "too many cycle iterations", then the problem is not dodging cycle detection. That error occurs when Salsa has detected the cycle, attempted to resolve it via fixpoint iteration, but then fixpoint iteration doesn't converge to a stable value. Often this is because we try to eagerly build an ever-more-deeply-recursively-nested type on each iteration, or because iteration flip-flops indefinitely between one value and another.

I haven't looked at this case in detail yet, but my suspicion is that the problem is the union simplification. That is, when we try to build the union type for MyUnion, we do a redundancy-relation check between each type in the union, which requires getting the fields of each type, which requires evaluating the annotation on each field, which requires building the MyUnion union type, which requires checking redundancy, which requires getting the fields of each type... I suspect that before we added Salsa caching to the fields method, we would have instead hit this cycle in the cached union-redundancy check, which probably converged to an answer more easily in fixpoint iteration since it's just a boolean result. But the fields query, which returns a map of field name to type, is more prone to divergence. In order to tell exactly how it's diverging, you'll need to inspect the values it returns on the problematic example. That may help us come up with ideas for how to fix it.

@MichaReiser
Copy link
Member

The way I try to debug those issues is to add a tracing::log to the cycle's recovery function where I log the previous and current value. This way you can see how the query converges (it might be something as simple as that the order of some fields or similar change)

@AlexWaygood
Copy link
Member

AlexWaygood commented Nov 19, 2025

New minimal repro. Seems sensitive to the order of appearance of different types within the union too.

from typing import Union, TypedDict

class Foo1(TypedDict):
    x1: MyUnion

class Foo2(TypedDict):
    x2: MyUnion

class Foo3(Foo2):
    x3: MyUnion

class Foo4(TypedDict):
    x4: MyUnion

MyUnion = Foo1 | Foo2 | Foo3 | Foo4

I can't reproduce a panic on your branch for this snippet, but I can if I add from __future__ import annotations to the top of the file. I'm invoking ty on a file inside the Ruff repo -- possibly you're invoking ty on a file in a slightly different location, which means that it's using our default Python version (3.14, which has deferred annotations by default at runtime) instead of the resolved Python version from Ruff's pyproject.toml file (3.7).

EDIT: ah, and I was explicitly passing --python-version=3.13 from the CLI to override Ruff's pyproject.toml.

@AlexWaygood
Copy link
Member

I tried out this change locally:

Patch
diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs
index 17b5fe7625..109a44e159 100644
--- a/crates/ty_python_semantic/src/types/class.rs
+++ b/crates/ty_python_semantic/src/types/class.rs
@@ -126,16 +126,6 @@ fn try_metaclass_cycle_initial<'db>(
     })
 }
 
-fn fields_cycle_initial<'db>(
-    _db: &'db dyn Db,
-    _id: salsa::Id,
-    _self: ClassLiteral<'db>,
-    _specialization: Option<Specialization<'db>>,
-    _field_policy: CodeGeneratorKind<'db>,
-) -> FxIndexMap<Name, Field<'db>> {
-    FxIndexMap::default()
-}
-
 /// A category of classes with code generation capabilities (with synthesized methods).
 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)]
 pub(crate) enum CodeGeneratorKind<'db> {
@@ -2339,7 +2329,7 @@ impl<'db> ClassLiteral<'db> {
 
                 // Use the alias name if provided, otherwise use the field name
                 let parameter_name =
-                    Name::new(alias.map(|alias| &**alias).unwrap_or(&**field_name));
+                    Name::new(alias.map(|alias| &**alias).unwrap_or(&*field_name));
 
                 let mut parameter = if is_kw_only {
                     Parameter::keyword_only(parameter_name)
@@ -2603,8 +2593,8 @@ impl<'db> ClassLiteral<'db> {
                 )))
             }
             (CodeGeneratorKind::TypedDict, "get") => {
-                let overloads = self
-                    .fields(db, specialization, field_policy)
+                let fields = self.fields(db, specialization, field_policy);
+                let overloads = fields
                     .iter()
                     .flat_map(|(name, field)| {
                         let key_type =
@@ -2834,7 +2824,6 @@ impl<'db> ClassLiteral<'db> {
     /// Returns a list of all annotated attributes defined in this class, or any of its superclasses.
     ///
     /// See [`ClassLiteral::own_fields`] for more details.
-    #[salsa::tracked(returns(ref), cycle_initial=fields_cycle_initial, heap_size=get_size2::GetSize::get_heap_size)]
     pub(crate) fn fields(
         self,
         db: &'db dyn Db,
diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs
index ed8b1be1ab..1542da24ac 100644
--- a/crates/ty_python_semantic/src/types/diagnostic.rs
+++ b/crates/ty_python_semantic/src/types/diagnostic.rs
@@ -35,7 +35,7 @@ use ruff_python_ast::parenthesize::parentheses_iterator;
 use ruff_python_ast::{self as ast, AnyNodeRef, Identifier};
 use ruff_python_trivia::CommentRanges;
 use ruff_text_size::{Ranged, TextRange};
-use rustc_hash::FxHashSet;
+use rustc_hash::{FxHashMap, FxHashSet};
 use std::fmt::Formatter;
 
 /// Registers all known type check lints.
@@ -3190,7 +3190,7 @@ pub(crate) fn report_invalid_key_on_typed_dict<'db>(
     typed_dict_ty: Type<'db>,
     full_object_ty: Option<Type<'db>>,
     key_ty: Type<'db>,
-    items: &FxIndexMap<Name, Field<'db>>,
+    items: &FxHashMap<Name, Field<'db>>,
 ) {
     let db = context.db();
     if let Some(builder) = context.report_lint(&INVALID_KEY, key_node) {
diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs
index 85c645d37a..90bbb33ea9 100644
--- a/crates/ty_python_semantic/src/types/infer/builder.rs
+++ b/crates/ty_python_semantic/src/types/infer/builder.rs
@@ -919,9 +919,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
                 CodeGeneratorKind::from_class(self.db(), class, None)
             {
                 let specialization = None;
+                let fields = class.fields(self.db(), specialization, field_policy);
 
-                let kw_only_sentinel_fields: Vec<_> = class
-                    .fields(self.db(), specialization, field_policy)
+                let kw_only_sentinel_fields: Vec<_> = fields
                     .iter()
                     .filter_map(|(name, field)| {
                         field.is_kw_only_sentinel(self.db()).then_some(name)
diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs
index c093dbef25..8e94a4fb1f 100644
--- a/crates/ty_python_semantic/src/types/typed_dict.rs
+++ b/crates/ty_python_semantic/src/types/typed_dict.rs
@@ -4,6 +4,7 @@ use ruff_db::parsed::parsed_module;
 use ruff_python_ast::Arguments;
 use ruff_python_ast::{self as ast, AnyNodeRef, StmtClassDef, name::Name};
 use ruff_text_size::Ranged;
+use rustc_hash::FxHashMap;
 
 use super::class::{ClassType, CodeGeneratorKind, Field};
 use super::context::InferContext;
@@ -12,10 +13,10 @@ use super::diagnostic::{
     report_missing_typed_dict_key,
 };
 use super::{ApplyTypeMappingVisitor, Type, TypeMapping, visitor};
+use crate::Db;
 use crate::types::constraints::ConstraintSet;
 use crate::types::generics::InferableTypeVars;
 use crate::types::{HasRelationToVisitor, IsDisjointVisitor, TypeContext, TypeRelation};
-use crate::{Db, FxIndexMap};
 
 use ordermap::OrderSet;
 
@@ -56,9 +57,25 @@ impl<'db> TypedDictType<'db> {
         self.defining_class
     }
 
-    pub(crate) fn items(self, db: &'db dyn Db) -> &'db FxIndexMap<Name, Field<'db>> {
-        let (class_literal, specialization) = self.defining_class.class_literal(db);
-        class_literal.fields(db, specialization, CodeGeneratorKind::TypedDict)
+    pub(crate) fn items(self, db: &'db dyn Db) -> &'db FxHashMap<Name, Field<'db>> {
+        #[salsa::tracked(returns(ref), cycle_initial=items_cycle_initial, heap_size=get_size2::GetSize::get_heap_size)]
+        fn items_inner<'db>(db: &'db dyn Db, class: ClassType<'db>) -> FxHashMap<Name, Field<'db>> {
+            let (class_literal, specialization) = class.class_literal(db);
+            class_literal
+                .fields(db, specialization, CodeGeneratorKind::TypedDict)
+                .into_iter()
+                .collect()
+        }
+
+        fn items_cycle_initial<'db>(
+            _db: &'db dyn Db,
+            _id: salsa::Id,
+            _class: ClassType<'db>,
+        ) -> FxHashMap<Name, Field<'db>> {
+            FxHashMap::default()
+        }
+
+        items_inner(db, self.defining_class)
     }

The idea of the change is to make it easier for the cycle to converge by returning an FxHashMap from the cached query rather than an FxIndexMap. An IndexMap cares about the order of the fields (which can make it hard for the query to converge) but a HashMap doesn't. We can't use an order-agnostic map for ClassLiteral::fields() because we care about the order of fields for dataclasses and namedtuples -- it allows us to perform certain useful checks -- so the caching is moved up "one level higher" to TypedDictType::items().

With that patch applied, we still panic on the repro from #21467 (comment) (providing you pass --python-version=3.14) -- the panic just moves to items_inner:

New query stacktrace
error[panic]: Panicked at /Users/alexw/.cargo/git/checkouts/salsa-e6f3bb7c2a062968/a885bb4/src/function/execute.rs:321:21 when checking `/Users/alexw/dev/ruff/foo.py`: `items_inner(Id(4403)): execute: too many cycle iterations`
info: This indicates a bug in ty.
info: If you could open an issue at https://github.com/astral-sh/ty/issues/new?title=%5Bpanic%5D, we'd be very appreciative!
info: Platform: macos aarch64
info: Version: ruff/0.14.5+57 (4160ea580 2025-11-18)
info: Args: ["target/debug/ty", "check", "foo.py", "--python-version=3.14"]
info: run with `RUST_BACKTRACE=1` environment variable to show the full backtrace information
info: query stacktrace:
   0: Type < 'db >::is_redundant_with_(Id(7407))
             at crates/ty_python_semantic/src/types.rs:821
   1: infer_definition_types(Id(1403))
             at crates/ty_python_semantic/src/types/infer.rs:94
             cycle heads: Type < 'db >::is_redundant_with_(Id(7400)) -> iteration = 200
   2: items_inner(Id(4404))
             at crates/ty_python_semantic/src/types/typed_dict.rs:61
   3: Type < 'db >::is_redundant_with_(Id(7400))
             at crates/ty_python_semantic/src/types.rs:821
   4: infer_definition_types(Id(1401))
             at crates/ty_python_semantic/src/types/infer.rs:94
   5: infer_scope_types(Id(1001))
             at crates/ty_python_semantic/src/types/infer.rs:70
   6: check_file_impl(Id(c00))
             at crates/ty_project/src/lib.rs:535

Note that (with or without my patch above), when invoking uv run cargo run --manifest-path=../ruff/Cargo.toml --profile=profiling -p ty check pydantic from the root of my pydantic clone, the panic is due to Type::is_redundant_with failing to converge rather than ClassLiteral::fields() or items_inner(). That also seems to be what we're seeing in the mypy_primer logs:

Pydantic query stacktrace
error[panic]: Panicked at /Users/alexw/.cargo/git/checkouts/salsa-e6f3bb7c2a062968/a885bb4/src/function/execute.rs:321:21 when checking `/Users/alexw/dev/pydantic/pydantic/functional_serializers.py`: `Type < 'db >::is_redundant_with_(Id(92ae0)): execute: too many cycle iterations`
info: This indicates a bug in ty.
info: If you could open an issue at https://github.com/astral-sh/ty/issues/new?title=%5Bpanic%5D, we'd be very appreciative!
info: Platform: macos aarch64
info: Version: ruff/0.14.5+57 (4160ea580 2025-11-18)
info: Args: ["/Users/alexw/dev/ruff/target/profiling/ty", "check", "pydantic"]
info: run with `RUST_BACKTRACE=1` environment variable to show the full backtrace information
info: query stacktrace:
   0: infer_definition_types(Id(221d2))
             at crates/ty_python_semantic/src/types/infer.rs:94
             cycle heads: Type < 'db >::is_redundant_with_(Id(63427)) -> iteration = 200
   1: items_inner(Id(15404))
             at crates/ty_python_semantic/src/types/typed_dict.rs:61
   2: Type < 'db >::is_redundant_with_(Id(92ae9))
             at crates/ty_python_semantic/src/types.rs:821
   3: infer_definition_types(Id(22194))
             at crates/ty_python_semantic/src/types/infer.rs:94
             cycle heads: Type < 'db >::is_redundant_with_(Id(63426)) -> iteration = 200
   4: items_inner(Id(2a40b))
             at crates/ty_python_semantic/src/types/typed_dict.rs:61
   5: Type < 'db >::is_redundant_with_(Id(63427))
             at crates/ty_python_semantic/src/types.rs:821
   6: infer_definition_types(Id(221e5))
             at crates/ty_python_semantic/src/types/infer.rs:94
   7: items_inner(Id(15405))
             at crates/ty_python_semantic/src/types/typed_dict.rs:61
   8: Type < 'db >::is_redundant_with_(Id(63426))
             at crates/ty_python_semantic/src/types.rs:821
   9: infer_deferred_types(Id(4c21))
             at crates/ty_python_semantic/src/types/infer.rs:141
  10: infer_scope_types(Id(3401))
             at crates/ty_python_semantic/src/types/infer.rs:70
  11: check_file_impl(Id(1407))
             at crates/ty_project/src/lib.rs:535

@AlexWaygood
Copy link
Member

This patch gets us passing on both minimal repros so far (#21467 (comment) and #21467 (comment)), and seems probably worth doing on its own merits, since it'll make cycles rarer and avoid us having to engage in a full structual check for many TypedDict assignability checks:

Patch
diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs
index 17b5fe7625..78beca70d1 100644
--- a/crates/ty_python_semantic/src/types/class.rs
+++ b/crates/ty_python_semantic/src/types/class.rs
@@ -566,7 +566,9 @@ impl<'db> ClassType<'db> {
                 },
 
                 // Protocol and Generic are not represented by a ClassType.
-                ClassBase::Protocol | ClassBase::Generic => ConstraintSet::from(false),
+                ClassBase::Protocol | ClassBase::Generic | ClassBase::TypedDict => {
+                    ConstraintSet::from(false)
+                }
 
                 ClassBase::Class(base) => match (base, other) {
                     (ClassType::NonGeneric(base), ClassType::NonGeneric(other)) => {
@@ -589,11 +591,6 @@ impl<'db> ClassType<'db> {
                         ConstraintSet::from(false)
                     }
                 },
-
-                ClassBase::TypedDict => {
-                    // TODO: Implement subclassing and assignability for TypedDicts.
-                    ConstraintSet::from(true)
-                }
             }
         })
     }
diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs
index 85c645d37a..449e770aff 100644
--- a/crates/ty_python_semantic/src/types/infer/builder.rs
+++ b/crates/ty_python_semantic/src/types/infer/builder.rs
@@ -8007,25 +8007,26 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
             // the `try_call` path below.
             // TODO: it should be possible to move these special cases into the `try_call_constructor`
             // path instead, or even remove some entirely once we support overloads fully.
-            let has_special_cased_constructor = matches!(
-                class.known(self.db()),
-                Some(
-                    KnownClass::Bool
-                        | KnownClass::Str
-                        | KnownClass::Type
-                        | KnownClass::Object
-                        | KnownClass::Property
-                        | KnownClass::Super
-                        | KnownClass::TypeAliasType
-                        | KnownClass::Deprecated
-                )
-            ) || (
-                // Constructor calls to `tuple` and subclasses of `tuple` are handled in `Type::Bindings`,
-                // but constructor calls to `tuple[int]`, `tuple[int, ...]`, `tuple[int, *tuple[str, ...]]` (etc.)
-                // are handled by the default constructor-call logic (we synthesize a `__new__` method for them
-                // in `ClassType::own_class_member()`).
-                class.is_known(self.db(), KnownClass::Tuple) && !class.is_generic()
-            );
+            let has_special_cased_constructor =
+                matches!(
+                    class.known(self.db()),
+                    Some(
+                        KnownClass::Bool
+                            | KnownClass::Str
+                            | KnownClass::Type
+                            | KnownClass::Object
+                            | KnownClass::Property
+                            | KnownClass::Super
+                            | KnownClass::TypeAliasType
+                            | KnownClass::Deprecated
+                    )
+                ) || (
+                    // Constructor calls to `tuple` and subclasses of `tuple` are handled in `Type::Bindings`,
+                    // but constructor calls to `tuple[int]`, `tuple[int, ...]`, `tuple[int, *tuple[str, ...]]` (etc.)
+                    // are handled by the default constructor-call logic (we synthesize a `__new__` method for them
+                    // in `ClassType::own_class_member()`).
+                    class.is_known(self.db(), KnownClass::Tuple) && !class.is_generic()
+                ) || CodeGeneratorKind::TypedDict.matches(self.db(), class.class_literal(self.db()).0, class.class_literal(self.db()).1);
 
             // temporary special-casing for all subclasses of `enum.Enum`
             // until we support the functional syntax for creating enum classes
diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs
index c093dbef25..bafe798425 100644
--- a/crates/ty_python_semantic/src/types/typed_dict.rs
+++ b/crates/ty_python_semantic/src/types/typed_dict.rs
@@ -90,6 +90,16 @@ impl<'db> TypedDictType<'db> {
         relation_visitor: &HasRelationToVisitor<'db>,
         disjointness_visitor: &IsDisjointVisitor<'db>,
     ) -> ConstraintSet<'db> {
+        // First do a quick nominal check that (if it succeeds) means that we can avoid
+        // materializing the full `TypeDict` schema for either `self` or `target`.
+        // This should be cheaper in many cases, and also helps us avoid some cycles.
+        if self
+            .defining_class
+            .is_subclass_of(db, target.defining_class)
+        {
+            return ConstraintSet::from(true);
+        }
+
         let self_items = self.items(db);
         let target_items = target.items(db);
         // Many rules violations short-circuit with "never", but asking whether one field is

We still panic when checking pydantic, even with that patch applied, however, so @oconnor663 will have to find a new minimal repro if he applies the patch ;)

oconnor663 and others added 4 commits November 19, 2025 08:49
@oconnor663
Copy link
Contributor Author

I'm invoking ty on a file inside the Ruff repo -- possibly you're invoking ty on a file in a slightly different location, which means that it's using our default Python version (3.14, which has deferred annotations by default at runtime) instead of the resolved Python version from Ruff's pyproject.toml file (3.7).

Yes I should've said I'm just running all of these snippets out of /tmp, so I think everything is ty defaults.

@oconnor663
Copy link
Contributor Author

oconnor663 commented Nov 19, 2025

Here's a new minimized repro (running as a standalone script out of /tmp), which still panics with @AlexWaygood's has_special_cased_constructor patch above:

from typing import TypedDict

class Foo1(TypedDict):
    x1: MyUnion

class Foo2(TypedDict):
    x2: MyUnion

class Foo3(Foo2):
    pass

class Foo4(Foo2):
    x3: MyUnion

class Foo5(TypedDict):
    x5: MyUnion

MyUnion = Foo1 | Foo3 | Foo4 | Foo5
error[panic]: Panicked at /home/jacko/.cargo/git/checkouts/salsa-e6f3bb7c2a062968/a885bb4/src/function/execute.rs:321:21 when checking `/tmp/core_schema.py`: `ClassLiteral < 'db >::fields_(Id(8803)): execute: too many cycle iterations`
info: This indicates a bug in ty.
info: If you could open an issue at https://github.com/astral-sh/ty/issues/new?title=%5Bpanic%5D, we'd be very appreciative!
info: Platform: linux x86_64
info: Version: ruff/0.14.5+62 (a2e803e65 2025-11-19)
info: Args: ["/home/jacko/astral/ruff/target-mold/debug/ty", "check", "core_schema.py"]
info: run with `RUST_BACKTRACE=1` environment variable to show the full backtrace information
info: query stacktrace:
   0: Type < 'db >::is_redundant_with_(Id(8407))
             at crates/ty_python_semantic/src/types.rs:821
   1: infer_definition_types(Id(1403))
             at crates/ty_python_semantic/src/types/infer.rs:94
             cycle heads: Type < 'db >::is_redundant_with_(Id(8400)) -> iteration = 200
   2: ClassLiteral < 'db >::fields_(Id(8800))
             at crates/ty_python_semantic/src/types/class.rs:1370
   3: Type < 'db >::is_redundant_with_(Id(8400))
             at crates/ty_python_semantic/src/types.rs:821
   4: infer_definition_types(Id(1401))
             at crates/ty_python_semantic/src/types/infer.rs:94
   5: infer_scope_types(Id(1001))
             at crates/ty_python_semantic/src/types/infer.rs:70
   6: check_file_impl(Id(c00))
             at crates/ty_project/src/lib.rs:535

It feels "pretty much the same" as the last one. What I'm noticing as I minimize these, is that there are a lot of "paths" I can take on the way down. I'll get a different result if I start ripping things out from the top vs from the bottom vs in the middle. Eventually I rip out something that removes the panic, so I put that thing back and roll the dice again on where to delete more stuff. So the end result is 1) one of many local minima I could wind up at and 2) maximally sensitive to the order of elements within the union. Which means that minor tweaks in how things are evaluated are likely to "fix" one of these minimal examples by coincidence without really fixing the underlying issue.

I'm going to try to follow @carljm and @MichaReiser's advice above and play around with logging this some more, to see if I can get some more intuition about what's going on.

@oconnor663
Copy link
Contributor Author

oconnor663 commented Nov 19, 2025

Making some progress with printouts. (Finally figured out that a "cycle recovery function" is cycle_fn. There are only two such examples in ty?) Here's what the values of .fields() look like right before the crash:

...
Foo5 (iteration #195)
    field x5: [missing] --> Foo5
Foo4 (iteration #195)
    field x2: Foo1|Foo3|Foo4|Foo5 --> Foo1|Foo3|Foo5
    field x4: Foo1|Foo3|Foo5 --> Foo5
Foo3 (iteration #195)
    field x2: Foo1|Foo3|Foo5 --> Foo5
Foo5 (iteration #196)
    field x5: Foo5 --> Foo1|Foo3|Foo5
Foo4 (iteration #196)
    field x2: Foo1|Foo3|Foo5 --> Foo5
    field x4: Foo5 --> Foo1|Foo3|Foo4|Foo5
Foo3 (iteration #196)
    field x2: Foo5 --> Foo1|Foo3|Foo4|Foo5
Foo5 (iteration #197)
    field x5: Foo1|Foo3|Foo5 --> Foo1|Foo3|Foo4|Foo5
Foo4 (iteration #197)
    field x2: Foo5 --> Foo1|Foo3|Foo4|Foo5
Foo3 (iteration #197)
    NO CHANGES!
Foo4 (iteration #198)
    field x4: Foo1|Foo3|Foo4|Foo5 --> Foo1|Foo3|Foo5
Foo3 (iteration #198)
    field x2: Foo1|Foo3|Foo4|Foo5 --> Foo1|Foo3|Foo5
Foo5 (iteration #199)
    field x5: [missing] --> Foo5
Foo4 (iteration #199)
    field x2: Foo1|Foo3|Foo4|Foo5 --> Foo1|Foo3|Foo5
    field x4: Foo1|Foo3|Foo5 --> Foo5
Foo3 (iteration #199)
    field x2: Foo1|Foo3|Foo5 --> Foo5

Notable that the union keeps collapsing down to a single TypedDict type. More staring required...

@carljm
Copy link
Contributor

carljm commented Nov 19, 2025

One thing that we could consider here that might help is to just never consider two named TypedDict types as redundant in a union, unless they are the exact same type. This would lead to less union simplification, but that might be fine, actually. And I suspect it would solve this problem.

@oconnor663
Copy link
Contributor Author

I'll try that. Separately, I've been refining the printout above, and now I see that sometimes my cycle_fn is being called when the previous value and current value are equal. I'm surprised by that, since I thought that was exactly the condition that ends the cycling. Will ask in chat.

@oconnor663
Copy link
Contributor Author

Also Alex mentioned at one point that he hasn't been able to get Protocol versions of examples like these to produce a similar panic. Not yet clear what's different between TypedDict and Protocol in this respect. I haven't looked into it yet.

@oconnor663 oconnor663 force-pushed the jack/typedict_assignment branch from d2f1d58 to c4c95ef Compare November 19, 2025 20:53
@oconnor663 oconnor663 force-pushed the jack/typedict_assignment branch from fd7ac18 to 17e3bf0 Compare November 19, 2025 21:04
@oconnor663
Copy link
Contributor Author

I think I'm seeing CI runs for later commits getting cancelled in favor of earlier commits, which doesn't make sense to me. Known problem? My fault for force-pushing something? In any case, I've manually restarted https://github.com/astral-sh/ruff/actions/runs/19516380284/job/55870895275, and I think that's the one to watch.

@oconnor663
Copy link
Contributor Author

Ok, everything is passing, except that CodSpeed reports a ~10x slowdown on the Pydantic benchmark. I'm going to try to isolate what parts are costing us the most time, but at the same time I need feedback on whether this is "obviously a performance bug" or "maybe reasonable given how convoluted their unions are"?

@carljm
Copy link
Contributor

carljm commented Nov 19, 2025

The magnitude of the regression is surprising to me. I definitely think we should explore how we can be more efficient here, but given that no other project regresses more than 1%, it's clearly a factor of the size and ubiquity of the typed dicts in pydantic; I wouldn't necessarily be opposed to going ahead and landing this and doing more optimization as a follow-up.

Copy link
Contributor

@carljm carljm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent work!

Comment on lines +501 to +502
class Amnesiac(TypedDict, total=False):
name: ReadOnly[str]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also explicitly test the total=False but mutable field case?

Comment on lines +512 to +513
`B` can have any assignable type. But if the item in `A` is mutable, the item type in `B` must match
exactly. The required and not-required cases are different codepaths, so we need test all the
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"match exactly" isn't a well defined term. "Equivalent" is, but I think that's not actually the right term here either; I think the right term here is "consistent". Which is the same thing as "mutually assignable".

The types int and Any, for example, do not "match exactly" (at least not as I would intuitively define that term; it isn't defined in the typing spec), nor are they equivalent (as defined in the typing spec), but they are mutually assignable, and they are "consistent" as defined in the spec (Any can materialize to int).

And this code does not have type errors:

from typing import Any, TypedDict

class A(TypedDict):
    x: Any

class B(TypedDict):
    x: int

def _(b: B) -> A:
    return b

def _(a: A) -> B:
    return a

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like you got this all correct (and described well) in the comments in the actual implementation, so I think this is just tightening up the terminology here, and maybe adding some tests as mentioned below.

# Not assignable.
# error: [invalid-assignment] "Object of type `Person` is not assignable to `Mapping[str, int]`"
_: Mapping[str, int] = alice
# TODO: Could be assignable once we support `closed=True` and/or `extra_items`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clearly we do want to support closed and extra_items at some point, but I don't think there is any TODO right at this spot. A TODO implies that our results on this code should be different, given better support. Person does not have closed=True or extra_items, so nothing we add support for in the future should change this error in this particular case.

If we added a test using a TypedDict that actually defines closed or extra_items and tested its assignability to a non-universal Mapping type (and got the wrong result for now), that would provide occasion for a TODO comment.

This seems like a good spot for an explanatory comment, but not for a TODO comment.

Suggested change
# TODO: Could be assignable once we support `closed=True` and/or `extra_items`.
# `Person` does not have `closed=True` or `extra_items`, so it may have additional keys with values
# of unknown type, therefore it can't be assigned to a `Mapping` with value type smaller than `object`.

permutations:

```py
from typing import Any
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We import Any here, but it doesn't seem to be used in this block of tests.

I think we should add some tests with typeddicts with Any typed field(s), showing how that impacts subtyping and assignability. Assignability should be forgiving (as shown in the comment above); subtyping should be strict.

reveal_type(e | e) # revealed: Employee

# TODO: Should be `Person` once we support subtyping for TypedDicts
# EXPERIMENT: Simplification of TypedDicts in Unions is disabled.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would probably keep this as a TODO:

Suggested change
# EXPERIMENT: Simplification of TypedDicts in Unions is disabled.
# TODO: Should be `Person`; simplifying TypedDicts in Unions is pending better cycle handling

Comment on lines +137 to +141
// For mutable fields in the target, the relation needs to apply both
// ways, or else mutating the target could violate the structural
// invariants of self. For fully-static types, this is "equivalence".
// For gradual types, it depends on the relation, but mutual
// assignability is "consistency".
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent comment!

Comment on lines +82 to +84
// Subtyping between `TypedDict`s follows the algorithm described at:
// https://typing.python.org/en/latest/spec/typeddict.html#subtyping-between-typeddict-types
pub(super) fn has_relation_to_impl(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a really clear and well-documented implementation of some fairly hairy logic!

@AlexWaygood AlexWaygood dismissed their stale review November 20, 2025 08:19

Requested changes were made

Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work!!

Since Carl already reviewed, I haven't done an exhaustive review of the semantics here vis-a-vis the spec -- just a few notes from me.

I think we should definitely look at improving perf here as a (post-beta?) followup. Aside from anything else, it's frustrating that the benchmarks CI job now takes 5 minutes longer than it used to 😆 But I do agree that we should land this now and move on; there's too much else to do before the beta.

I don't think you have any tests currently for generic typeddicts. It looks like everything works correctly on your branch, but it would be great to test it explicitly; something like this?

from typing import TypedDict
from ty_extensions import static_assert, is_assignable_to, is_subtype_of

class F[T](TypedDict):
    x: T

class G[T](TypedDict):
    x: T

static_assert(is_assignable_to(F, G))

def x[T](a: T) -> T:
    static_assert(is_subtype_of(F[T], G[T]))
    return a

static_assert(is_subtype_of(F[int], G[int]))
static_assert(not is_assignable_to(F[int], G[str]))

Some tests that use the legacy syntax (multiple-inheriting from TypedDict and Generic[T]) would be great as well.

I ran the property tests on this branch and didn't see any issues. Though I don't think we include any TypedDict types in the property tests right now, so that's not a massive surprise 😆. We should probably open a followup issue for that -- again, that should probably be done post-beta.

Comment on lines +755 to +757
def _(o1: Outer1, o2: Outer2):
_: Outer1 = o2
_: Outer2 = o1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'd personally find these more readable if we used the static_assert(is_assignable_to(...)) pattern here

Comment on lines +780 to +795
def _(o1: Outer1, o2: Outer2, o3: Outer3, o4: Outer4):
_: Outer1 = o3
_: Outer1 = o4

_: Outer2 = o3
_: Outer2 = o4

_: Outer3 = o1
_: Outer3 = o2
_: Outer3 = o3
_: Outer3 = o4

_: Outer4 = o1
_: Outer4 = o2
_: Outer4 = o3
_: Outer4 = o4
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

///
/// See [`ClassLiteral::own_fields`] for more details.
#[salsa::tracked(returns(ref), heap_size=get_size2::GetSize::get_heap_size)]
#[salsa::tracked(returns(ref), cycle_initial=fields_cycle_initial, heap_size=get_size2::GetSize::get_heap_size)]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: quite a long line

Suggested change
#[salsa::tracked(returns(ref), cycle_initial=fields_cycle_initial, heap_size=get_size2::GetSize::get_heap_size)]
#[salsa::tracked(
returns(ref),
cycle_initial=fields_cycle_initial,
heap_size=get_size2::GetSize::get_heap_size)
]

relation_visitor: &HasRelationToVisitor<'db>,
disjointness_visitor: &IsDisjointVisitor<'db>,
) -> ConstraintSet<'db> {
// First do a quick nominal check that (if it succeeds) means that we can avoid
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just talked with Alex about the 10x performance regression on pydantic here, caused by the large union of TypedDicts (many of which are probably not subtypes of each other). When we build that union, we need to perform O(n²) subtyping checks, so it's understandable that we see a huge regression now that we actually do nontrivial work in those checks. Other than the nominal check here (which is probably not super fast, actually), are there any other short circuit paths where we could return false early? The most common case might be something like the following where two "normal" TypedDicts (no extra_items or similar) are simply incompatible because their key names are not compatible:

class A(TypedDict):
    key_a: int

class B(TypedDict):
    key_b: int

Is there something we can do by just looking at the names, without ever looking at the value types?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ecosystem-analyzer ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement structural assignability for TypedDicts

6 participants