Skip to content

Update unpack TypedDict kwargs forwarding spec + add conformance tests - #2338

Open
yangdanny97 wants to merge 6 commits into
python:mainfrom
yangdanny97:typeddict-kwargs-passing
Open

Update unpack TypedDict kwargs forwarding spec + add conformance tests#2338
yangdanny97 wants to merge 6 commits into
python:mainfrom
yangdanny97:typeddict-kwargs-passing

Conversation

@yangdanny97

Copy link
Copy Markdown
Contributor

Previously, this section of the spec was not exercised at all in the conformance tests, and also was not implemented by any type checker.

This PR updates the spec to account for closed & extra_items TypedDicts, and adds conformance tests.

There are 3 asserted errors:

  • open TypedDict kwargs being unpacked into function w/o kwargs (no type checker implements this)
  • extra_items TypedDict kwargs being unpacked into function w/o kwargs (only ty implements this)
  • extra_items TypedDict kwargs being unpacked into function w/ incompatibly-typed kwargs (ty, pyright, and zuban implement this)

This would supersede #1960, which proposed that we delete the section of the spec entirely.
@rchen152's comment in https://discuss.python.org/t/typing-spec-inconsistency-for-unpacking-typed-dict-kwargs/79640 favors deletion.

@yangdanny97 yangdanny97 changed the title Add tests for unpack TypedDict kwargs forwarding Update unpack TypedDict kwargs forwarding spec + add conformance tests Aug 11, 2026
@carljm

carljm commented Aug 11, 2026

Copy link
Copy Markdown
Member

This issue is nuanced, because many unpacking behaviors in Python are unsafe. If you unpack a list[int] into a function that takes (int, int) (but not *args: int), every type checker allows that, even though the list could obviously contain fewer or more than two elements. The collective decision seems to be that since length is not part of what the list type encodes, we trust the user rather than erroring.

In ty we made the decision not to error on the first case here, because we saw large mypy-primer fallout if we enforced this rule, and we felt it was analogous to the case of list[int] -- an open TypedDict may contain extra elements (just like a list may contain any number of elements), but the type doesn't explicitly say that it does, so we can be forgiving here.

We decided to be stricter with extra_items, in part because there is no widespread usage yet, so usage will adapt to what type checkers permit, and in part because in this case the user has explicitly declared their intent for the TypedDict to contain extra items, so it feels reasonable to account for that more strictly. This does mean that implicitly open typed dicts are not equivalent to extra_items=ReadOnly[object], which maybe in principle they would be -- but I think this is currently true in all type checkers.

I think ty's compromise is reasonable, but I'm not totally convinced it's the best option, open to other resolutions here. But enforcing this rule on all implicitly open TypedDicts will have a significant impact on existing real-world code.

@yangdanny97

Copy link
Copy Markdown
Contributor Author

For Pyrefly my current thought is that this would be off-by-default and enabled in strict mode.

@carljm

carljm commented Aug 11, 2026

Copy link
Copy Markdown
Member

That seems reasonable. I'm not sure if behavior that we wouldn't turn on by default in our own type checkers should be encoded as a conformance suite requirement, though?

It seems to me that a strict mode which requires safety in unpacking an open TypedDict should probably also require safety in unpacking a list/Sequence?

@yangdanny97

yangdanny97 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

So do you think that we should just merge #1960 and delete this section from the typing spec entirely?

Or weaken the first assertion to "may" and "E?"

@carljm

carljm commented Aug 11, 2026

Copy link
Copy Markdown
Member

I would probably land this with the first assertion weakened (and discussion of that in the spec also), but that's not a strong preference, it just matches what we decided to do in ty, so naturally it already makes sense to me :) Very open to other opinions here.

@yangdanny97

Copy link
Copy Markdown
Contributor Author

Does it make sense to differentiate between implicitly open and explicitly open (closed=False), by making errors mandatory for the latter?

@carljm

carljm commented Aug 12, 2026

Copy link
Copy Markdown
Member

Does it make sense to differentiate between implicitly open and explicitly open (closed=False), by making errors mandatory for the latter?

It might. Currently in ty we only apply the extra strictness with extra_items=, not with closed=False. I think the appeal of this is that keeps closed itself boolean rather than tri-valued (that is, closed=False is the default and providing it explicitly makes no difference), but closed=False alone is not the same thing as explicitly providing an extra_items type -- the latter more explicitly says "I expect there to be extra items in this dict of a particular type". But all of this is a judgment call, there's no clear right or wrong answer. Or rather, the "right" answer in the abstract is probably the universal strictness you initially proposed, it's just a question of whether that's workable in the ecosystem.

@JelleZijlstra

Copy link
Copy Markdown
Member

The current TypedDict spec says that closed=False means the TypedDict is open, i.e. consistent with the default. I think we should stick with the TypedDict spec chapter's clear division of TypedDicts into states, and allow unsound behavior only if the TypedDict is open (as defined by the spec; the spec change in this PR should link to the glossary definition for the term).

In the abstract I'd prefer to treat open TypedDicts similar to ones with extra_items=ReadOnly[object], but I agree that's probably a bridge too far right now.

@carljm carljm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you!

Comment thread docs/spec/callables.rst Outdated
would not cause errors at runtime during function invocation. Otherwise, the
type checker should generate an error.
Therefore, it is only safe to pass ``kwargs`` hinted with an unpacked, non-:term:`closed` ``TypedDict``
to another function if that function has ``**kwargs`` in its signature as well. Type checkers should

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we also explicitly require possible extra-item values to be assignable to the destination's **kwargs annotation? Having **kwargs alone isn't sufficient:

class Open(TypedDict):
    name: str

def target(name: str, **kwargs: int) -> None: ...

def forward(**kwargs: Unpack[Open]) -> None:
    target(**kwargs)  # Hidden extra items have type object, not int.

Both ty and pyright reject this, while zuban currently accepts it. I think the optional-error compromise should apply to unexpected keyword names, not to values that are incompatible with an explicitly annotated parameter. Could we spell out that distinction and cover forwarding an open TypedDict into typed **kwargs?

conformant = "Pass"
conformant = "Partial"
notes = """
Does not reject passing unpacked kwargs typed with a non-closed TypedDict to a callable that has no `**kwargs`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we remove this note here and from the other checker results? The updated spec explicitly allows an open TypedDict to be forwarded without an error, and the corresponding test is marked E?, so accepting that case isn't a conformance failure. (The separate note about explicitly declared extra_items still makes sense.)

Comment thread docs/spec/callables.rst
Comment thread conformance/tests/callables_kwargs.py Outdated
# > should error if the ``TypedDict`` has ``extra_items``, and may error if the ``TypedDict`` is open.

def func8(**kwargs: Unpack[TD3]) -> None:
takes_name(**kwargs) # E?: a subtype may contain unknown keys

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we also cover directly unpacking ordinary TypedDict-annotated parameters or locals, not just a function's own **kwargs? The existing spec already demonstrates this with baz(animal: Animal), and the same rules should apply regardless of where the TypedDict came from:

def forward_direct(value: TD5) -> None:
    takes_name(**value)  # E: extra items may be present
    takes_name_str_kwargs(**value)  # E: int is incompatible with str

It would be useful to include the corresponding open and closed cases too, so a checker can't pass by special-casing **kwargs provenance.

Comment thread conformance/tests/callables_kwargs.py Outdated
name: str


class TD4(TypedDict, closed=True):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could we keep an explicitly open TypedDict(..., closed=False) case alongside the implicitly open one, and also add extra_items=Never alongside closed=True? The discussion specifically settled that closed=False should behave like the default, and the TypedDict spec defines extra_items=Never as equivalent to closed=True. Without both spellings, a checker can incorrectly distinguish them and still pass these tests.

notes = """
Allows callable without kwargs to be assigned to callable with unpacked kwargs.
Does not support the `closed` and `extra_items` TypedDict class arguments.
Does not reject passing unpacked kwargs typed with a non-closed TypedDict to a callable that has no `**kwargs`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Remove this note? Accepting an open TypedDict here is explicitly permitted by the updated spec, and the corresponding test is marked E?. The separate extra_items note is the actual conformance issue.

conformance_automated = "Pass"
conformant = "Partial"
notes = """
Does not reject passing unpacked kwargs typed with a non-closed TypedDict to a callable that has no `**kwargs`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Remove

conformance_automated = "Pass"
conformant = "Partial"
notes = """
Does not reject passing unpacked kwargs typed with a non-closed TypedDict to a callable that has no `**kwargs`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Remove

conformance_automated = "Pass"
conformant = "Partial"
notes = """
Does not reject passing unpacked kwargs typed with a non-closed TypedDict to a callable that has no `**kwargs`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Remove

Comment thread docs/spec/callables.rst Outdated
to has ``**kwargs`` in its signature as well, because then additional keywords
would not cause errors at runtime during function invocation. Otherwise, the
type checker should generate an error.
Therefore, it is only safe to pass ``kwargs`` hinted with an unpacked, non-:term:`closed` ``TypedDict``

@carljm carljm Aug 13, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This rule shouldn't be specific to forwarding kwargs -- but I guess that would imply a larger rewrite of this whole section. (Which I think we should do, but maybe not in this PR.) The existing text is way too focused on specific cases rather than outlining the general principles.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I can reword this part a little bit in this PR, but let's save a bigger rewrite for later

@carljm carljm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think there are a couple details here in the spec (one backwards statement, one broken link) that should be addressed -- everything else here is up to you whether you want to include it in this PR. Otherwise this looks ready to me.

I'm glad we are clearing this up now, before wider adoption of closed/extra_items TypedDicts.

Comment thread docs/spec/callables.rst
``**kwargs`` should be disallowed. This is because, we cannot be sure that
additional keyword arguments are not being passed in when an instance of a
subclass had been assigned to a variable with a base class type and then
``**kwargs`` should be disallowed, if the TypedDict is :term:`closed`. This is because

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this condition is backwards: a closed TypedDict cannot contain hidden keys, so assigning a callable without **kwargs is safe in precisely that case. The new tests also accept both closed=True and extra_items=Never, while rejecting the open case.

Should this say "if the TypedDict is not closed" instead? The copied specification quotation in the test would need the same fix.

def kwargs_extra_items(value: TDExtraItems, **kwargs: Unpack[TDExtraItems]) -> None:
takes_name(**value) # E: extra items may be present
takes_name_str_kwargs(**value) # E: extra items type is not compatible
takes_name_int_kwargs(**value)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we also cover the case where an extra item binds an explicitly declared optional parameter before it reaches **kwargs?

class Extras(TypedDict, extra_items=int):
    name: str

def target(name: str, *, label: str = "", **kwargs: int) -> None: ...

def forward(value: Extras) -> None:
    target(**value)  # Extras could supply label=1.

Matching extra_items=int just against **kwargs: int is not sufficient because label expects str.

Comment thread docs/spec/callables.rst
Therefore, it is only safe to unpack a non-:term:`closed` TypedDict in a function call
if that function has ``**kwargs`` in its signature, and any :term:`extra items` are assignable to the type of ``**kwargs``.
If the function being called has ``**kwargs``, checkers should error if the TypedDict's extra items are not assignable to the type of ``**kwargs``.
If the function being called does not have ``**kwargs``, checkers may error if the TypedDict is :term:`open`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we explicitly state what happens when a TypedDict has declared, non-Never extra_items and the destination does not have **kwargs?

This paragraph currently requires an error for incompatible annotated **kwargs and permits an error for an open TypedDict without destination **kwargs, but it never says the declared-extra-items/no-**kwargs combination must error. The new conformance tests do require that error and downgrade several checkers for omitting it, so I think we need the corresponding normative requirement here.

Comment thread docs/spec/callables.rst
type checker should generate an error.
Therefore, it is only safe to unpack a non-:term:`closed` TypedDict in a function call
if that function has ``**kwargs`` in its signature, and any :term:`extra items` are assignable to the type of ``**kwargs``.
If the function being called has ``**kwargs``, checkers should error if the TypedDict's extra items are not assignable to the type of ``**kwargs``.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does the reference to extra items here formally cover an open TypedDict? The glossary and TypedDict chapter define "open" and "has extra items" as distinct states, but the new tests require the possible additional values of an open TypedDict to be checked as object against annotated **kwargs.

Could we say explicitly that, for this rule, an open TypedDict is treated as having read-only additional items of type object, while a TypedDict with extra_items= uses its declared extra-item type? Otherwise the mandatory open-TypedDict errors in the tests do not follow from the defined terminology.

...


def takes_name_int_kwargs(name: str, **kwargs: int) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would it be useful to also cover destinations whose **kwargs are themselves annotated with Unpack[TypedDict]?

def takes_closed(**kwargs: Unpack[TDClosed]) -> None: ...

def forward(value: TDExtraItems) -> None:
    takes_closed(**value)  # Extra keys are not allowed by TDClosed.

Also unpacking e.g. an open TypedDict into a destination with Unpack of a TypedDict with extra_items=int?

All of the destination helpers currently use untyped or homogeneous **kwargs.

Comment thread docs/spec/callables.rst

Passing kwargs inside a function to another function
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Unpacking a TypedDict as keyword arguments

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The new conformance test still links to #passing-kwargs-inside-a-function-to-another-function, but this heading will generate #unpacking-a-typeddict-as-keyword-arguments instead.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants