FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

`copy.replace` rejects a union whose members each have a field the others lack (since #14819) · Issue #16582 · python/typeshed · GitHub

Repository navigation

copy.replace rejects a union whose members each have a field the others lack (since #14819) #16582

Open

Description

Warning

LLM Disclosure

This post was authored by claude-opus-5-5 on behalf of @JPHutchins. While working out JPHutchins/salix#359 (a dataclass_transform library whose replace stub has the same ParamSpec-protocol shape as copy.replace), JP asked me to report the upstream causes with evidence.

Since #14819 typed copy.replace through _SupportsReplace[_P, _RT_co], a union argument is rejected whenever each member has a field the others lack, such as Circle | Square sharing name.

  • ty 0.0.90, which bundles Make copy.replace more strongly typed #14819, rejects copy.replace(shape, name="renamed") today. mypy 2.4.0, which also bundles it, rejects the call and returns object.
  • pyright 1.1.414 (released 2026-09-09) rejects it against main's signature; its bundled typeshed predates Make copy.replace more strongly typed #14819.
  • Unions that still pass: identical fields (Left | Right), or one member's fields containing the other's.
  • The call is fine at runtime.

Proposed fix: a second overload that no argument can match. Checkers then apply the spec's argument type expansion and check each union member on its own, and changes stay typed. Measured on all three: rename passes and wrong is still refused. mypy still returns object for bump, its join, linked below.

import copy
from dataclasses import dataclass


@dataclass(frozen=True)
class Circle:
    name: str
    radius: float


@dataclass(frozen=True)
class Square:
    name: str
    side: float


@dataclass(frozen=True)
class Left:
    x: int


@dataclass(frozen=True)
class Right:
    x: int


def rename(shape: Circle | Square) -> Circle | Square:
    return copy.replace(shape, name="renamed")


def bump(value: Left | Right) -> Left | Right:
    return copy.replace(value, x=1)


def wrong(shape: Circle | Square) -> Circle | Square:
    return copy.replace(shape, name=1)
at a 3.14 target rename bump wrong
ty 0.0.63, bundled, before #14819 ok ok not checked
ty 0.0.90, bundled, with #14819 rejected ok refused
pyright 1.1.414, bundled, before #14819 ok ok not checked
pyright 1.1.414, main's signature rejected ok refused
mypy 2.4.0, bundled, with #14819 rejected, object object refused
all three, main's signature plus the overload below ok mypy: object; pyright and ty: ok refused
Exact output

ty 0.0.90 (bundled copy.replace):

repro.py:28:25: error[invalid-argument-type] Argument to function `replace` is incorrect: Expected `_SupportsReplace[((*, name: str = ..., radius: float = ...)) | ((*, name: str = ..., side: float = ...)), Circle | Square]`, found `Circle | Square`

pyright 1.1.414, against main's signature (its bundled typeshed is 289e5d3, from 2026-06-24, so I put main's _SupportsReplace and replace in a local stub):

repro_tsmain.py:28:25 - error: Argument of type "Circle | Square" cannot be assigned to parameter "obj" of type "_SupportsReplace[_P@replace, _RT_co@replace]" in function "replace"

mypy 2.4.0 (bundled):

repro.py:28: error: Incompatible return value type (got "object", expected "Circle | Square")  [return-value]
repro.py:28: error: Argument 1 to "replace" has incompatible type "Circle | Square"; expected "_SupportsReplace[[DefaultNamedArg(str, 'name'), DefaultNamedArg(float, 'radius')], object]"  [arg-type]
repro.py:32: error: Incompatible return value type (got "object", expected "Left | Right")  [return-value]

At run time on CPython 3.14.8, rename(Square("s", 1.0)) returns Square(name='renamed', side=1.0) and bump(Right(0)) returns Right(x=1).

The proposed overloads, and why they work
@final
@type_check_only
class _Unreplaceable: ...

@overload
def replace(obj: _SupportsReplace[_P, _RT_co], /, *_: _P.args, **changes: _P.kwargs) -> _RT_co: ...
@overload
def replace(obj: _Unreplaceable, /, **changes: object) -> Never: ...
  • mypy only splits the union once the whole union fails the first overload. Left | Right matches it as a whole through the join, so bump stays object.
  • On sentinel choice: with obj: Never as the second overload's parameter, mypy reports overload-cannot-match on this stub. A @final class no argument is an instance of avoids that.

From the typing spec's overload call evaluation, step 3:

If step 2 produces errors for all overloads, perform "argument type expansion". Union types can be expanded into their constituent subtypes.

If all argument lists evaluate successfully, combine their respective return types by union to determine the final return type for the call, and stop.

pyright's maintainer has explained why a ParamSpec can't bind a union of signatures, and has pointed to overloads for other ParamSpec-and-union shapes. Neither thread is about copy.replace:

  • pyright#4760: “A ParamSpec cannot take on multiple signatures. There's no way to "widen" a signature to include multiple signatures.” and “Perhaps an overload would satisfy your use case?”
  • pyright#11152: "The correct way to annotate this function is with an overload."
Related

Activity

  1. changed the title [-]`copy.replace` rejects a union argument whose members' `__replace__` signatures differ (since #14819)[/-] [+]`copy.replace` rejects a union whose members each have a field the others lack (since #14819)[/+] on Oct 21, 2026
  2. JPHutchins commented on Oct 21, 2026

    ContributorAuthor

    Warning

    LLM Disclosure

    This post was authored by claude-opus-5-5 on behalf of @JPHutchins. It adds a cost of the proposed overloads, measured after the issue was filed.

    A cost of the proposed overloads: a wrong change on a single argument loses its specific error on mypy and ty. pyright keeps its specific message, and both other checkers still report an error, but a less useful one. With overloads, a call that fails every overload is reported as a whole.

    @dataclass(frozen=True)
    class Circle:
        name: str
        radius: float
    
    
    def wrong(shape: Circle) -> Circle:
        return copy.replace(shape, name=1)
    at a 3.14 target main's signature main's signature plus the two overloads
    mypy 2.4.0 Argument "name" to "replace" has incompatible type "int"; expected "str" [arg-type] No overload variant of "replace" matches argument types "Circle", "int" [call-overload], plus Returning Any from function declared to return "Circle" [no-any-return]
    ty 0.0.90 Argument to function replace is incorrect: Expected str, found Literal[1] No overload of function replace matches arguments
    pyright 1.1.414 Argument of type "Literal[1]" cannot be assigned to parameter "name" of type "str" the same, after No overloads for "replace" match the provided arguments

    So the overloads trade sharper messages on single-argument mistakes, the common case, for accepting unions whose members each have a field the others lack. JPHutchins/salix#359 measured the same trade in salix's own stub and chose to keep the precise messages, documenting a workaround for union call sites instead.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions


      Back | FazBrowse Home | New Git URL