Skip to content

Fix argument type annotation of defstruct()#1014

Open
ypnos wants to merge 2 commits intojcrist:mainfrom
ypnos:patch-1
Open

Fix argument type annotation of defstruct()#1014
ypnos wants to merge 2 commits intojcrist:mainfrom
ypnos:patch-1

Conversation

@ypnos
Copy link
Copy Markdown

@ypnos ypnos commented Apr 10, 2026

Previously, defstruct() argument "fields" was annotated to be an iterable of tuples which contain "type" objects.

However, defstruct also reads and understands unions of types, as also documented in the example at https://jcristharif.com/msgspec/api.html#msgspec.defstruct

This patch fixes mypy complaining when passing a union.

Notes:

  1. types.UnionType was introduced in Python 3.10, which is currently the minimum Python version for msgspec.
  2. Since Python 3.14, types.UnionType is an alias for typing.Union
  3. We might consider also explicitely supporting typing.Union here for Python <= 3.14 to support people spelling out Union[int | str] instead of int | str
  4. We might also consider adapting the typing notation for optionals and unions in msgspec files, like the file patched here, to the pipe symbol (which as also introduced in 3.10). There is tools that do this, e.g. ruff.

Previously, defstruct() argument "fields" was annotated to be an iterable of tuples which contain "type" objects.

However, defstruct also reads and understands unions of types, as also documented in the example at https://jcristharif.com/msgspec/api.html#msgspec.defstruct

This patch fixes mypy complaining when passing a union.
Copy link
Copy Markdown
Owner

@jcrist jcrist left a comment

Choose a reason for hiding this comment

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

Thanks!

@Siyet
Copy link
Copy Markdown
Collaborator

Siyet commented Apr 11, 2026

Thanks for catching this @ypnos! You're right that the current annotation is too narrow - mypy shouldn't reject int | None here.

I think we can take this a bit further though. The runtime doesn't actually validate the type field at all - it just stores whatever you pass into __annotations__ (see msgspec_defstruct in _core.c). So in addition to int | None, all of these are valid at runtime today and should typecheck:

  • Optional[int] (a _UnionGenericAlias, not a UnionType)
  • list[int] / List[int]
  • Annotated[int, Meta(ge=0)]
  • Literal["a", "b"]

In fact the docstring example for defstruct uses set[str], which the current PR still wouldn't accept 😄

Rather than enumerating each typing form (which is hard to keep complete), would you be open to annotating the type slot as Any? That's the approach typeshed takes for dataclasses.make_dataclass:

fields: Iterable[str | tuple[str, Any] | tuple[str, Any, Any]]

It's a single change, drops the UnionType import, and matches the runtime contract exactly.

It would also be great to extend check_defstruct_field_types in tests/typing/basic_typing_examples.py with a few of the forms above, so we don't regress on this in the future.

Happy to help with either of those if it's easier - just let me know!

As it is very hard to cover all cases of allowed type form annotations, use Any instead.

This is in line with `dataclasses.make_dataclass`. PEP 747 could improve this situation in the future.

Co-authored-by: Jim Crist-Harif <jcristharif@gmail.com>
@ypnos
Copy link
Copy Markdown
Author

ypnos commented Apr 13, 2026

Thank you @jcrist and @Siyet for looking into this and providing a suggestion on how to solve this best. I fixed it up with most relevant facts in the commit message.

Would you like me to squash this into a single commit?

I'm not sure I will find the time myself to add test cases for type form scenarios.

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