Skip to content

feat: add mypy plugin for type-checking structs.replace kwargs#992

Open
lukasK9999 wants to merge 1 commit intojcrist:mainfrom
lukasK9999:feat/mypy-replace-plugin
Open

feat: add mypy plugin for type-checking structs.replace kwargs#992
lukasK9999 wants to merge 1 commit intojcrist:mainfrom
lukasK9999:feat/mypy-replace-plugin

Conversation

@lukasK9999
Copy link
Copy Markdown

Summary

  • Adds msgspec.mypy plugin that validates keyword arguments passed to msgspec.structs.replace()
  • Without the plugin, mypy accepts any kwargs due to the **changes: Any signature in the stubs — including invalid field names and wrong types
  • Follows the same approach used by mypy's built-in attrs evolve() plugin: hooks into get_function_signature_hook and generates a call-site-specific signature with the struct's fields as optional keyword arguments

Usage

# mypy.ini or pyproject.toml
[mypy]
plugins = [msgspec.mypy]

What it catches

class User(msgspec.Struct):
    name: str
    age: int

u = User(name="Alice", age=30)

# These now produce mypy errors:
replace(u, invalid=42)      # Unexpected keyword argument "invalid"
replace(u, name=42)         # Incompatible type "int"; expected "str"

Supported cases

  • Plain and frozen structs
  • Generic structs (Struct[T])
  • Unions of structs (field types are intersected)
  • TypeVar bounds
  • Any passthrough (no false positives)
  • Function return values as first argument

Test plan

  • 18 new tests in tests/typing/test_mypy_plugin.py — all passing
  • Existing tests/typing/test_mypy.py still passes (no regression)

🤖 Generated with Claude Code

Add `msgspec.mypy` plugin that validates keyword arguments passed to
`msgspec.structs.replace()`. Without the plugin, mypy accepts any kwargs
due to the `**changes: Any` signature in the stubs.

The plugin hooks into `get_function_signature_hook` and generates a
call-site-specific signature with the struct's fields as optional keyword
arguments, following the same approach used by mypy's built-in attrs
`evolve()` plugin.

Supports: plain structs, frozen structs, generics, unions of structs,
TypeVar bounds, and Any passthrough.

Usage:
    # mypy.ini or pyproject.toml
    [mypy]
    plugins = [msgspec.mypy]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@lukasK9999
Copy link
Copy Markdown
Author

lukasK9999 commented Mar 25, 2026

Mypy support for replace is something I've been missing when using msgspec. We use frozen structs heavily, and without this we usually default to attrs, which has this built in via its mypy plugin.

The implementation closely follows mypy's existing attrs evolve() plugin — the core algorithm is essentially the same (get the init signature, extract field names/types, build a typed CallableType), with the only real difference being struct detection via MRO lookup instead of the attrs_attrs marker.

It's AI-generated, but I've tested it against all the cases I could think of: plain/frozen structs, generics, unions,
TypeVar bounds, invalid fields, wrong types, and Any passthrough. Happy to adjust anything to fit the project's
conventions.

@UnknownPlatypus
Copy link
Copy Markdown

Given that the attrs and dataclass mypy plugin are part of mypy, would it make sense to have this there too ? If you closely copied attrs implementation, there is maybe even room to share common parts ?

@lukasK9999
Copy link
Copy Markdown
Author

attrs is somewhat exceptional historically. It predates dataclasses, and dataclasses were directly inspired by attrs, so mypy’s built-in support for both evolved together around very similar semantics. Since dataclasses are stdlib, core support in mypy is necessary; attrs then remained as an established special case. For newer third-party libraries, the more common pattern is to ship plugin support in the library itself (e.g. Pydantic), which is why keeping msgspec support in msgspec currently seems closer to current practice.

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.

2 participants