Skip to content

[ty] Add support for functional TypedDict#24174

Merged
charliermarsh merged 5 commits intomainfrom
charlie/typed-dict-1
Mar 30, 2026
Merged

[ty] Add support for functional TypedDict#24174
charliermarsh merged 5 commits intomainfrom
charlie/typed-dict-1

Conversation

@charliermarsh
Copy link
Copy Markdown
Member

@charliermarsh charliermarsh commented Mar 25, 2026

Summary

This PR adds basic support for functional TypedDict construction, including recursive TypedDicts. The intent is to follow the patterns we've established for functional NamedTuple and type(...) calls as closely as we can.

There are two follow-up PRs that were carved out to make them easier to review:

(My intent is to merge the stack once all three are approved. E.g., the new false positive in the ecosystem test is fixed in #24176.)

Part of: astral-sh/ty#3095.

@astral-sh-bot astral-sh-bot bot added the ty Multi-file analysis & type inference label Mar 25, 2026
@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot bot commented Mar 25, 2026

Typing conformance results improved 🎉

The percentage of diagnostics emitted that were expected errors increased from 86.59% to 86.60%. The percentage of expected errors that received a diagnostic increased from 80.96% to 81.47%. The number of fully passing files improved from 68/132 to 69/132.

Summary

How are test cases classified?

Each test case represents one expected error annotation or a group of annotations sharing a tag. Counts are per test case, not per diagnostic — multiple diagnostics on the same line count as one. Required annotations (E) are true positives when ty flags the expected location and false negatives when it does not. Optional annotations (E?) are true positives when flagged but true negatives (not false negatives) when not. Tagged annotations (E[tag]) require ty to flag exactly one of the tagged lines; tagged multi-annotations (E[tag+]) allow any number up to the tag count. Flagging unexpected locations counts as a false positive.

Metric Old New Diff Outcome
True Positives 859 866 +7 ⏫ (✅)
False Positives 133 134 +1 ⏫ (❌)
False Negatives 202 197 -5 ⏬ (✅)
Total Diagnostics 1051 1063 +12
Precision 86.59% 86.60% +0.01% ⏫ (✅)
Recall 80.96% 81.47% +0.51% ⏫ (✅)
Passing Files 68/132 69/132 +1 ⏫ (✅)

Test file breakdown

4 files altered
File True Positives False Positives False Negatives Status
typeddicts_alt_syntax.py 6 (+6) ✅ 0 1 (-3) ✅ 📈 Improving
typeddicts_extra_items.py 9 (+1) ✅ 22 (+1) ❌ 19 (-1) ✅ ➡️ Neutral
typeddicts_readonly.py 6 (+1) ✅ 0 0 (-1) ✅ ✅ Newly Passing 🎉
typeddicts_type_consistency.py 9 (-1) ❌ 0 0 ✅ Still Passing
Total (all files) 866 (+7) ✅ 134 (+1) ❌ 197 (-5) ✅ 69/132

True positives added (5)

5 diagnostics
Test case Diff

typeddicts_alt_syntax.py:23

+error[invalid-argument-type] Expected a dict literal for parameter `fields` of `TypedDict()`

typeddicts_alt_syntax.py:27

+error[invalid-argument-type] Expected a string-literal key in the `fields` dict of `TypedDict()`: Found `Literal[1]`

typeddicts_alt_syntax.py:35

+error[unknown-argument] Argument `other` does not match any known parameter of function `TypedDict`

typeddicts_extra_items.py:22

+error[invalid-key] Unknown key "year" for TypedDict `MovieFunctional`

typeddicts_readonly.py:36

+error[invalid-assignment] Cannot assign to key "members" on TypedDict `Band2`: key is marked read-only

False positives added (1)

1 diagnostic
Test case Diff

typeddicts_extra_items.py:21

+error[invalid-key] Unknown key "novel_adaptation" for TypedDict `MovieFunctional`

Optional Diagnostics Added (3)

3 diagnostics
Test case Diff

typeddicts_alt_syntax.py:41

+error[missing-argument] No argument provided for required parameter `fields` of function `TypedDict`
+error[unknown-argument] Argument `name` does not match any known parameter of function `TypedDict`
+error[unknown-argument] Argument `year` does not match any known parameter of function `TypedDict`

typeddicts_alt_syntax.py:44

+error[invalid-key] Unknown key "name" for TypedDict `Movie2`
+error[invalid-key] Unknown key "year" for TypedDict `Movie2`

typeddicts_alt_syntax.py:45

+error[invalid-key] Unknown key "name" for TypedDict `Movie2`
+error[invalid-key] Unknown key "year" for TypedDict `Movie2`

Optional Diagnostics Removed (1)

1 diagnostic
Test case Diff

typeddicts_type_consistency.py:101

-error[invalid-assignment] Object of type `Unknown | None` is not assignable to `str`

@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot bot commented Mar 25, 2026

Memory usage report

Summary

Project Old New Diff Outcome
prefect 715.67MB 716.74MB +0.15% (1.07MB)
sphinx 264.83MB 265.21MB +0.14% (385.16kB)
trio 117.76MB 117.99MB +0.20% (236.12kB)
flake8 48.03MB 48.07MB +0.10% (47.22kB)

Significant changes

Click to expand detailed breakdown

prefect

Name Old New Diff Outcome
infer_definition_types 88.72MB 89.72MB +1.14% (1.01MB)
infer_deferred_types 14.68MB 14.72MB +0.29% (43.51kB)
Type<'db>::class_member_with_policy_ 17.71MB 17.72MB +0.02% (3.93kB)
infer_expression_types_impl 61.76MB 61.76MB +0.00% (2.92kB)
infer_expression_type_impl 13.81MB 13.82MB +0.02% (2.48kB)
StaticClassLiteral<'db>::implicit_attribute_inner_ 9.93MB 9.93MB +0.02% (2.15kB)
StaticClassLiteral<'db>::implicit_attribute_inner_::interned_arguments 5.27MB 5.27MB +0.03% (1.59kB)
all_narrowing_constraints_for_expression 7.13MB 7.13MB +0.02% (1.55kB)
FunctionType 8.68MB 8.68MB +0.01% (960.00B)
Type<'db>::try_call_dunder_get_ 10.75MB 10.75MB +0.01% (944.00B)
is_redundant_with_impl 5.51MB 5.51MB -0.02% (924.00B)
is_redundant_with_impl::interned_arguments 5.42MB 5.42MB -0.02% (880.00B)
CallableType 2.08MB 2.08MB +0.04% (864.00B)
Type<'db>::class_member_with_policy_::interned_arguments 9.59MB 9.59MB +0.01% (832.00B)
Type<'db>::member_lookup_with_policy_ 16.19MB 16.19MB -0.00% (812.00B)
... 36 more

sphinx

Name Old New Diff Outcome
infer_definition_types 23.60MB 23.97MB +1.59% (384.16kB)
infer_deferred_types 5.61MB 5.61MB +0.02% (1.00kB)

trio

Name Old New Diff Outcome
infer_definition_types 7.50MB 7.73MB +3.06% (235.22kB)
infer_deferred_types 2.38MB 2.38MB +0.04% (928.00B)

flake8

Name Old New Diff Outcome
infer_definition_types 1.82MB 1.87MB +2.53% (47.22kB)

@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot bot commented Mar 25, 2026

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-argument-type 36 3 3
invalid-await 40 0 0
missing-typed-dict-key 12 1 0
unused-type-ignore-comment 0 13 0
invalid-key 7 3 0
unresolved-attribute 0 7 0
invalid-assignment 5 0 0
invalid-method-override 5 0 0
invalid-return-type 2 2 0
no-matching-overload 1 0 0
Total 108 29 3

Changes in flaky projects detected. Raw diff output excludes flaky projects; see the HTML report for details.

Raw diff (98 changes)
artigraph (https://github.com/artigraph/artigraph)
+ src/arti/types/python.py:258:13 error[invalid-argument-type] Expected a dict literal for parameter `fields` of `TypedDict()`

dragonchain (https://github.com/dragonchain/dragonchain)
+ dragonchain/lib/dto/api_key_model.py:175:9 error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `permissions_doc`, found `permissions_doc | dict[str, str | bool | dict[Unknown, Unknown]]`
+ dragonchain/lib/dto/api_key_model.py:209:9 error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `permissions_doc`, found `permissions_doc | dict[str, str | bool | dict[Unknown, Unknown]]`
+ dragonchain/transaction_processor/level_3_actions_utest.py:247:86 error[invalid-argument-type] Argument to function `verify_blocks` is incorrect: Expected `L1Headers`, found `dict[str, str]`
+ dragonchain/webserver/helpers_utest.py:169:47 error[invalid-argument-type] Argument to function `verify_custom_indexes_options` is incorrect: Expected `Iterable[custom_index]`, found `list[dict[str, str]]`
+ dragonchain/webserver/helpers_utest.py:169:48 error[missing-typed-dict-key] Missing required key 'options' in TypedDict `custom_index` constructor
+ dragonchain/webserver/helpers_utest.py:172:47 error[invalid-argument-type] Argument to function `verify_custom_indexes_options` is incorrect: Expected `Iterable[custom_index]`, found `list[dict[str, str]]`
+ dragonchain/webserver/helpers_utest.py:172:48 error[missing-typed-dict-key] Missing required key 'options' in TypedDict `custom_index` constructor
+ dragonchain/webserver/helpers_utest.py:175:47 error[invalid-argument-type] Argument to function `verify_custom_indexes_options` is incorrect: Expected `Iterable[custom_index]`, found `list[dict[str, str]]`
+ dragonchain/webserver/helpers_utest.py:175:48 error[missing-typed-dict-key] Missing required key 'options' in TypedDict `custom_index` constructor
+ dragonchain/webserver/lib/api_keys_utest.py:48:66 error[invalid-argument-type] Argument to function `create_api_key_v1` is incorrect: Expected `permissions_doc | None`, found `dict[str, str]`
+ dragonchain/webserver/lib/api_keys_utest.py:48:87 error[missing-typed-dict-key] Missing required key 'default_allow' in TypedDict `permissions_doc` constructor
+ dragonchain/webserver/lib/api_keys_utest.py:48:87 error[missing-typed-dict-key] Missing required key 'permissions' in TypedDict `permissions_doc` constructor
+ dragonchain/webserver/lib/api_keys_utest.py:48:87 error[missing-typed-dict-key] Missing required key 'version' in TypedDict `permissions_doc` constructor
+ dragonchain/webserver/lib/api_keys_utest.py:48:88 error[invalid-key] Unknown key "wind" for TypedDict `permissions_doc`
+ dragonchain/webserver/lib/api_keys_utest.py:91:59 error[invalid-argument-type] Argument to function `update_api_key_v1` is incorrect: Expected `permissions_doc | None`, found `dict[str, str]`
+ dragonchain/webserver/lib/api_keys_utest.py:91:59 error[missing-typed-dict-key] Missing required key 'default_allow' in TypedDict `permissions_doc` constructor
+ dragonchain/webserver/lib/api_keys_utest.py:91:59 error[missing-typed-dict-key] Missing required key 'permissions' in TypedDict `permissions_doc` constructor
+ dragonchain/webserver/lib/api_keys_utest.py:91:59 error[missing-typed-dict-key] Missing required key 'version' in TypedDict `permissions_doc` constructor
+ dragonchain/webserver/lib/api_keys_utest.py:91:60 error[invalid-key] Unknown key "definitely" for TypedDict `permissions_doc`
+ dragonchain/lib/database/redisearch_utest.py:71:55 error[invalid-argument-type] Argument to function `create_transaction_index` is incorrect: Expected `Iterable[custom_index] | None`, found `list[dict[str, str]]`
+ dragonchain/lib/database/redisearch_utest.py:71:56 error[missing-typed-dict-key] Missing required key 'options' in TypedDict `custom_index` constructor
+ dragonchain/transaction_processor/level_4_actions_utest.py:73:63 error[invalid-argument-type] Argument to function `verify_blocks` is incorrect: Expected `L1Headers`, found `dict[str, int | str]`
+ dragonchain/transaction_processor/level_4_actions_utest.py:73:73 error[invalid-argument-type] Invalid argument to key "dc_id" with declared type `str` on TypedDict `L1Headers`: value of type `Literal[123]`
+ dragonchain/transaction_processor/level_4_actions_utest.py:73:90 error[invalid-argument-type] Invalid argument to key "block_id" with declared type `str` on TypedDict `L1Headers`: value of type `Literal[124]`

graphql-core (https://github.com/graphql-python/graphql-core)
- tests/utilities/test_build_client_schema.py:90:53 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- tests/utilities/test_build_client_schema.py:498:54 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- tests/utilities/test_build_client_schema.py:673:44 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- tests/utilities/test_build_client_schema.py:682:42 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- tests/utilities/test_build_client_schema.py:987:60 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- tests/utilities/test_build_client_schema.py:1002:55 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive

hydra-zen (https://github.com/mit-ll-responsible-ai/hydra-zen)
- src/hydra_zen/wrapper/_implementations.py:1572:89 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive

isort (https://github.com/pycqa/isort)
- isort/output.py:587:25 error[invalid-argument-type] Argument to function `import_statement` is incorrect: Expected `Sequence[str]`, found `@Todo(Functional TypedDicts) | None | list[Unknown]`
+ isort/output.py:587:25 error[invalid-argument-type] Argument to function `import_statement` is incorrect: Expected `Sequence[str]`, found `Any | None | list[Unknown]`
- isort/output.py:597:25 error[invalid-argument-type] Argument to function `import_statement` is incorrect: Expected `Sequence[str]`, found `@Todo(Functional TypedDicts) | None | list[Unknown]`
+ isort/output.py:597:25 error[invalid-argument-type] Argument to function `import_statement` is incorrect: Expected `Sequence[str]`, found `Any | None | list[Unknown]`
- isort/output.py:605:29 error[invalid-argument-type] Argument to function `import_statement` is incorrect: Expected `Sequence[str]`, found `@Todo(Functional TypedDicts) | None | list[Unknown]`
+ isort/output.py:605:29 error[invalid-argument-type] Argument to function `import_statement` is incorrect: Expected `Sequence[str]`, found `Any | None | list[Unknown]`

meson (https://github.com/mesonbuild/meson)
- mesonbuild/cargo/manifest.py:330:16 error[missing-typed-dict-key] Missing required key 'workspace' in TypedDict `FromWorkspace` constructor
- mesonbuild/cargo/manifest.py:330:17 error[invalid-key] Unknown key "version" for TypedDict `FromWorkspace`
- mesonbuild/cargo/manifest.py:542:40 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `LibTarget`, found `dict[Unknown, Unknown]`
- mesonbuild/cargo/manifest.py:557:54 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `BuildTarget`, found `dict[str, str]`
- mesonbuild/cargo/manifest.py:563:54 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `BuildTarget`, found `dict[str, str]`
+ mesonbuild/cargo/manifest.py:336:22 error[no-matching-overload] No overload of function `_depv_to_dep` matches arguments
+ unittests/cargotests.py:332:40 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `Manifest`, found `dict[str, object]`
+ unittests/cargotests.py:333:33 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `Manifest`, found `dict[str, dict[str, str] | FromWorkspace]`
+ unittests/cargotests.py:333:45 error[missing-typed-dict-key] Missing required key 'version' in TypedDict `Package` constructor
+ unittests/cargotests.py:340:33 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `Manifest`, found `dict[str, dict[str, str]]`
+ unittests/cargotests.py:340:45 error[missing-typed-dict-key] Missing required key 'version' in TypedDict `Package` constructor
+ unittests/cargotests.py:350:40 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `Manifest`, found `dict[str, object]`
+ unittests/cargotests.py:364:40 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `Manifest`, found `dict[str, object]`
+ unittests/cargotests.py:400:42 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `Manifest`, found `dict[str, object]`
+ unittests/cargotests.py:417:42 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `Manifest`, found `dict[str, object]`
+ unittests/cargotests.py:441:42 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `Manifest`, found `dict[str, object]`
+ unittests/cargotests.py:457:42 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `Manifest`, found `dict[str, object]`
+ unittests/cargotests.py:497:42 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `Manifest`, found `dict[str, object]`
+ unittests/cargotests.py:504:38 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `Manifest`, found `dict[str, object]`
+ unittests/cargotests.py:515:42 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `Manifest`, found `dict[str, object]`
+ unittests/cargotests.py:549:42 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `Manifest`, found `dict[str, object]`
+ unittests/cargotests.py:569:42 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `Manifest`, found `dict[str, object]`
- unittests/cargotests.py:379:68 error[invalid-key] Unknown key "optional" for TypedDict `FromWorkspace`
+ unittests/cargotests.py:379:48 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `FromWorkspace | Dependency | str`, found `dict[str, bool]`
- unittests/cargotests.py:387:62 error[invalid-key] Unknown key "features" for TypedDict `FromWorkspace`
+ unittests/cargotests.py:387:42 error[invalid-argument-type] Argument to bound method `from_raw` is incorrect: Expected `FromWorkspace | Dependency | str`, found `dict[str, bool | list[str]]`

operator (https://github.com/canonical/operator)
+ ops/_private/harness.py:1640:9 error[invalid-assignment] Invalid subscript assignment with key of type `tuple[str | None, int | None]` and value of type `dict[str, list[dict[str, str | list[dict[str, str]]]] | list[str]]` on object of type `dict[tuple[str | None, int | None], _NetworkDict]`
+ ops/_private/harness.py:3355:38 error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `str | LayerDict | None`, found `str | (LayerDict & Top[dict[Unknown, Unknown]]) | (Layer & Top[dict[Unknown, Unknown]])`
+ ops/model.py:3738:16 error[invalid-return-type] Return type does not match returned value: expected `_StatusDict`, found `dict[str, str]`
+ ops/model.py:3739:23 error[invalid-argument-type] Invalid argument to key "status" with declared type `Literal["active", "blocked", "maintenance", "waiting", "error", "unknown"]` on TypedDict `_StatusDict`: value of type `str`
- ops/pebble.py:630:58 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- ops/pebble.py:720:58 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- ops/pebble.py:789:58 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- ops/pebble.py:1170:40 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- ops/pebble.py:1188:40 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
+ ops/pebble.py:884:9 error[invalid-method-override] Invalid override of method `__eq__`: Definition is incompatible with `object.__eq__`
+ ops/pebble.py:946:9 error[invalid-method-override] Invalid override of method `__eq__`: Definition is incompatible with `object.__eq__`
+ ops/pebble.py:963:28 error[invalid-assignment] Object of type `(ServiceDict & ~AlwaysFalsy) | dict[Unknown, Unknown]` is not assignable to `ServiceDict`
+ ops/pebble.py:1033:9 error[invalid-method-override] Invalid override of method `__eq__`: Definition is incompatible with `object.__eq__`
+ ops/pebble.py:1101:26 error[invalid-assignment] Object of type `(CheckDict & ~AlwaysFalsy) | dict[Unknown, Unknown]` is not assignable to `CheckDict`
+ ops/pebble.py:1148:9 error[invalid-method-override] Invalid override of method `__eq__`: Definition is incompatible with `object.__eq__`
+ ops/pebble.py:1171:27 error[invalid-key] TypedDict `ExecDict` can only be subscripted with a string literal key, got key of type `str`.
+ ops/pebble.py:1173:27 error[invalid-key] TypedDict `ExecDict` can only be subscripted with a string literal key, got key of type `str & ~Literal["environment"]`.
+ ops/pebble.py:1189:27 error[invalid-key] TypedDict `HttpDict` can only be subscripted with a string literal key, got key of type `str`.
+ ops/pebble.py:1191:27 error[invalid-key] TypedDict `HttpDict` can only be subscripted with a string literal key, got key of type `str & ~Literal["headers"]`.
+ ops/pebble.py:1204:22 error[invalid-key] TypedDict `TcpDict` can only be subscripted with a string literal key, got key of type `str`.
+ ops/pebble.py:1262:30 error[invalid-assignment] Object of type `(LogTargetDict & ~AlwaysFalsy) | dict[Unknown, Unknown]` is not assignable to `LogTargetDict`
+ ops/pebble.py:1286:9 error[invalid-method-override] Invalid override of method `__eq__`: Definition is incompatible with `object.__eq__`
+ ops/pebble.py:2103:23 error[invalid-argument-type] Invalid argument to key "access" with declared type `Literal["untrusted", "metrics", "read", "admin"]` on TypedDict `IdentityDict`: value of type `str`
+ ops/pebble.py:2556:36 error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `str | LayerDict | None`, found `(LayerDict & Top[dict[Unknown, Unknown]]) | (Layer & Top[dict[Unknown, Unknown]])`

pandera (https://github.com/pandera-dev/pandera)
- pandera/engines/pandas_engine.py:1420:76 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive

ppb-vector (https://github.com/ppb/ppb-vector)
- tests/test_length.py:41:20 error[unresolved-attribute] Object of type `int | float` has no attribute `length`
- tests/test_project.py:50:8 error[unresolved-attribute] Object of type `int | float` has no attribute `isclose`
- tests/test_scalar_multiplication.py:10:28 error[unresolved-attribute] Object of type `int | float` has no attribute `x`
- tests/test_scalar_multiplication.py:11:28 error[unresolved-attribute] Object of type `int | float` has no attribute `y`
- tests/test_scalar_multiplication.py:19:12 error[unresolved-attribute] Object of type `int | float` has no attribute `isclose`
- tests/test_scalar_multiplication.py:24:12 error[unresolved-attribute] Object of type `int | float` has no attribute `isclose`
- tests/test_scalar_multiplication.py:32:20 error[unresolved-attribute] Object of type `int | float` has no attribute `length`
- ppb_vector/__init__.py:567:16 error[invalid-return-type] Return type does not match returned value: expected `Vector`, found `int | float`
- ppb_vector/__init__.py:588:16 error[invalid-return-type] Return type does not match returned value: expected `tuple[Vector, Vector]`, found `tuple[int | float, Vector]`

scipy-stubs (https://github.com/scipy/scipy-stubs)
+ scipy-stubs/integrate/_quadpack_py.pyi:77:43 error[invalid-argument-type] Expected a string-literal key in the `fields` dict of `TypedDict()`: Found `Literal[0]`

static-frame (https://github.com/static-frame/static-frame)
+ static_frame/test/unit/test_type_clinic.py:198:39 error[invalid-argument-type] Expected a dict literal for parameter `fields` of `TypedDict()`
+ static_frame/test/unit/test_type_clinic.py:199:39 error[invalid-argument-type] Expected a dict literal for parameter `fields` of `TypedDict()`

Full report with detailed diff (timing results)

@charliermarsh
Copy link
Copy Markdown
Member Author

For the ecosystem changes...

@carljm carljm assigned AlexWaygood and unassigned oconnor663 Mar 25, 2026
@carljm carljm removed their request for review March 25, 2026 18:19
Copy link
Copy Markdown
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 in depth at the code yet, just at the tests so far.

@charliermarsh charliermarsh force-pushed the charlie/typed-dict-1 branch 3 times, most recently from 42d26d8 to f7f0130 Compare March 26, 2026 01:03
@AlexWaygood
Copy link
Copy Markdown
Member

It looks like we should be able to get rid of the DynamicType::TodoFunctionalTypedDict variant as part of this PR? No tests fail on this branch if I apply this diff:

Details
diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs
index 0656296c08..e5566f2ccf 100644
--- a/crates/ty_python_semantic/src/types.rs
+++ b/crates/ty_python_semantic/src/types.rs
@@ -929,7 +929,6 @@ impl<'db> Type<'db> {
             DynamicType::Todo(_)
             | DynamicType::TodoStarredExpression
             | DynamicType::TodoUnpack
-            | DynamicType::TodoFunctionalTypedDict
             | DynamicType::TodoTypeVarTuple => true,
         })
     }
@@ -3759,32 +3758,6 @@ impl<'db> Type<'db> {
                 }
             },
 
-            Type::SpecialForm(SpecialFormType::TypedDict) => {
-                Binding::single(
-                    self,
-                    Signature::new(
-                        Parameters::new(
-                            db,
-                            [
-                                Parameter::positional_only(Some(Name::new_static("typename")))
-                                    .with_annotated_type(KnownClass::Str.to_instance(db)),
-                                Parameter::positional_only(Some(Name::new_static("fields")))
-                                    .with_annotated_type(KnownClass::Dict.to_instance(db))
-                                    .with_default_type(Type::any()),
-                                Parameter::keyword_only(Name::new_static("total"))
-                                    .with_annotated_type(KnownClass::Bool.to_instance(db))
-                                    .with_default_type(Type::bool_literal(true)),
-                                // Future compatibility, in case new keyword arguments will be added:
-                                Parameter::keyword_variadic(Name::new_static("kwargs"))
-                                    .with_annotated_type(Type::any()),
-                            ],
-                        ),
-                        Type::Dynamic(DynamicType::TodoFunctionalTypedDict),
-                    ),
-                )
-                .into()
-            }
-
             Type::NominalInstance(_) | Type::ProtocolInstance(_) | Type::NewTypeInstance(_) => {
                 // Note that for objects that have a (possibly not callable!) `__call__` attribute,
                 // we will get the signature of the `__call__` attribute, but will pass in the type
@@ -6011,7 +5984,6 @@ impl<'db> Type<'db> {
                 | DynamicType::TodoStarredExpression
                 | DynamicType::TodoTypeVarTuple
                 | DynamicType::UnspecializedTypeVar
-                | DynamicType::TodoFunctionalTypedDict
             )
             | Self::Callable(_)
             | Self::TypeIs(_)
@@ -6495,8 +6467,6 @@ pub enum DynamicType<'db> {
     TodoStarredExpression,
     /// A special Todo-variant for `TypeVarTuple` instances encountered in type expressions
     TodoTypeVarTuple,
-    /// A special Todo-variant for functional `TypedDict`s.
-    TodoFunctionalTypedDict,
     /// A type that is determined to be divergent during recursive type inference.
     Divergent(DivergentType),
 }
@@ -6523,7 +6493,6 @@ impl std::fmt::Display for DynamicType<'_> {
             DynamicType::TodoUnpack => f.write_str("@Todo(typing.Unpack)"),
             DynamicType::TodoStarredExpression => f.write_str("@Todo(StarredExpression)"),
             DynamicType::TodoTypeVarTuple => f.write_str("@Todo(TypeVarTuple)"),
-            DynamicType::TodoFunctionalTypedDict => f.write_str("@Todo(Functional TypedDicts)"),
             DynamicType::Divergent(_) => f.write_str("Divergent"),
         }
     }
diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs
index f120dfb33c..735b3d64d2 100644
--- a/crates/ty_python_semantic/src/types/call/bind.rs
+++ b/crates/ty_python_semantic/src/types/call/bind.rs
@@ -2067,12 +2067,6 @@ impl<'db> Bindings<'db> {
                         _ => {}
                     },
 
-                    Type::SpecialForm(SpecialFormType::TypedDict) => {
-                        overload.set_return_type(Type::Dynamic(
-                            crate::types::DynamicType::TodoFunctionalTypedDict,
-                        ));
-                    }
-
                     // Not a special case
                     _ => {}
                 }
diff --git a/crates/ty_python_semantic/src/types/class_base.rs b/crates/ty_python_semantic/src/types/class_base.rs
index 22a730a33e..5138f51ebc 100644
--- a/crates/ty_python_semantic/src/types/class_base.rs
+++ b/crates/ty_python_semantic/src/types/class_base.rs
@@ -59,7 +59,6 @@ impl<'db> ClassBase<'db> {
             ClassBase::Dynamic(DynamicType::UnspecializedTypeVar) => "UnspecializedTypeVar",
             ClassBase::Dynamic(
                 DynamicType::Todo(_)
-                | DynamicType::TodoFunctionalTypedDict
                 | DynamicType::TodoUnpack
                 | DynamicType::TodoStarredExpression
                 | DynamicType::TodoTypeVarTuple,
diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs
index 43cf155ae5..f98e5dab39 100644
--- a/crates/ty_python_semantic/src/types/infer/builder.rs
+++ b/crates/ty_python_semantic/src/types/infer/builder.rs
@@ -4077,13 +4077,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
                 let in_typed_dict = current_scope.kind() == ScopeKind::Class
                     && nearest_enclosing_class(self.db(), self.index, self.scope()).is_some_and(
                         |class| {
-                            class.iter_mro(self.db(), None).any(|base| {
-                                matches!(
-                                    base,
-                                    ClassBase::TypedDict
-                                        | ClassBase::Dynamic(DynamicType::TodoFunctionalTypedDict)
-                                )
-                            })
+                            class
+                                .iter_mro(self.db(), None)
+                                .contains(&ClassBase::TypedDict)
                         },
                     );
                 if !in_typed_dict {
@@ -5870,13 +5866,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
             }
         }
 
-        // Avoid false positives for the functional `TypedDict` form, which is currently
-        // unsupported.
-        if let Some(Type::Dynamic(DynamicType::TodoFunctionalTypedDict)) = tcx.annotation {
-            return KnownClass::Dict
-                .to_specialized_instance(self.db(), &[Type::unknown(), Type::unknown()]);
-        }
-
         let items = items
             .iter()
             .map(|item| [item.key.as_ref(), Some(&item.value)])
diff --git a/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs b/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs
index bd36a0649d..b230d1dfa2 100644
--- a/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs
+++ b/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs
@@ -357,9 +357,6 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
             (typevar @ Type::Dynamic(DynamicType::UnspecializedTypeVar), _, _)
             | (_, typevar @ Type::Dynamic(DynamicType::UnspecializedTypeVar), _) => Some(typevar),
 
-            (todo @ Type::Dynamic(DynamicType::TodoFunctionalTypedDict), _, _)
-            | (_, todo @ Type::Dynamic(DynamicType::TodoFunctionalTypedDict), _) => Some(todo),
-
             // When both operands are the same constrained TypeVar (e.g., `T: (int, str)`),
             // we check if the operation is valid for each constraint paired with itself.
             // This is different from treating it as a union, where we'd check all combinations.
diff --git a/crates/ty_python_semantic/src/types/typevar.rs b/crates/ty_python_semantic/src/types/typevar.rs
index e02b52e8e4..6a0c77c0e6 100644
--- a/crates/ty_python_semantic/src/types/typevar.rs
+++ b/crates/ty_python_semantic/src/types/typevar.rs
@@ -541,7 +541,6 @@ impl<'db> TypeVarInstance<'db> {
                     DynamicType::Todo(_)
                     | DynamicType::TodoUnpack
                     | DynamicType::TodoStarredExpression
-                    | DynamicType::TodoFunctionalTypedDict
                     | DynamicType::TodoTypeVarTuple => Parameters::todo(),
                     DynamicType::Any
                     | DynamicType::Unknown

I pushed that as a commit to 592c30c if that's easier for you to apply it!

@charliermarsh
Copy link
Copy Markdown
Member Author

(Mid-way through addressing locally, just marking as "resolved" once I've addressed on my local branch, recognize they aren't resolved on GH yet...)

@charliermarsh
Copy link
Copy Markdown
Member Author

I put this up here so the diff is easy to review: #24253

@AlexWaygood
Copy link
Copy Markdown
Member

AlexWaygood commented Mar 27, 2026

Okay, having seen #24253, you've persuaded me that the lenient fallback for invalidly defined TypedDicts probably isn't worth it. But in that case, I think we can just get rid of the FunctionalTypedDictSpec struct entirely and just use TypedDictSchema directly! I pushed a proof of concept as 97f0339, if you like the look of that.

@charliermarsh
Copy link
Copy Markdown
Member Author

(Applied!)

@AlexWaygood
Copy link
Copy Markdown
Member

(I pushed a couple more simplifications/fixes, hope that's okay!)

Copy link
Copy Markdown
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.

One more thing I noticed, but otherwise this looks great to me now! (And the other two PRs LGTM too.)

As I mentioned offline, though, it would be great if you could merge the three PRs into one before I do a final approval. I haven't looked at the conformance or ecosystem reports yet -- it's a bit hard to do so while the work is split between three PRs.

@charliermarsh
Copy link
Copy Markdown
Member Author

Sounds good, I will address that last comment then merge the other two PRs in here.

## Summary

This PR adds handling for class-based TypedDicts that inherit from
functional TypedDicts.

Part of: astral-sh/ty#3095.
@charliermarsh
Copy link
Copy Markdown
Member Author

Okay, all merged, reviewing ecosystem now...

@charliermarsh
Copy link
Copy Markdown
Member Author

charliermarsh commented Mar 27, 2026

There's a diagnostic for:

error[invalid-argument-type]: Expected a dict literal for parameter `fields` of `TypedDict()`
Record1 = tp.TypedDict('Record1', dict(a=int, b=float, c=str))

And then scipy-stubs has:

error[invalid-argument-type]: Expected a string-literal key in the `fields` dict of `TypedDict()`
_QuadExplain = TypedDict("_QuadExplain", {0: str, 1: str, 2: str, 3: str, 4: str, 5: str})

Pyright rejects both of these.

@charliermarsh
Copy link
Copy Markdown
Member Author

I'm inclined to support Record1 = tp.TypedDict('Record1', dict(a=int, b=float, c=str)) since it's so easy.

@charliermarsh
Copy link
Copy Markdown
Member Author

Eh, nevermind.

@AlexWaygood AlexWaygood force-pushed the charlie/typed-dict-1 branch from 6ab8cf8 to 1354606 Compare March 29, 2026 16:23
@AlexWaygood AlexWaygood changed the title [ty] Add core support for functional TypedDict [ty] Add support for functional TypedDict Mar 29, 2026
@AlexWaygood
Copy link
Copy Markdown
Member

AlexWaygood commented Mar 29, 2026

It looks like there's just one thing we're missing here to get us to be fully passing on the typeddicts_alt_syntax.py file in the typing conformance suite: we need to emit a diagnostic on this snippet:

from typing import TypedDict

BadTypedDict3 = TypedDict("WrongName", {"name": str})  # E

Because "WrongName" doesn't match the name the TypedDict was assigned to (BadTypedDict3).

If you ask me, this is needlessly pedantic and something that should really be the job of a linter rather than a type checker. But it's pretty easy for us to detect this, so we may as well emit a diagnostic for it (but maybe it should just be a warning by default).

We could easily add this as a followup, though; it doesn't need to be done in this PR.

@charliermarsh
Copy link
Copy Markdown
Member Author

Sounds good. Happy to do that as a follow-up!

Copy link
Copy Markdown
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!!

@charliermarsh charliermarsh merged commit 2a61c60 into main Mar 30, 2026
49 checks passed
@charliermarsh charliermarsh deleted the charlie/typed-dict-1 branch March 30, 2026 00:21
sharkdp added a commit that referenced this pull request Mar 30, 2026
## Summary

Given `BadTypedDict = TypedDict("WrongName", {"name": str})`, the
conformance test suite suggests we need to raise a diagnostic due to the
mismatch between `BadTypedDict` and `WrongName`.

See:
#24174 (comment).

---------

Co-authored-by: David Peter <mail@david-peter.de>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants