Skip to content

feat(types): adopter inheritance pattern triggers ~50 type: ignore[assignment] #624

@bokelley

Description

@bokelley

Problem

The recommended adopter pattern — extend a library type and override a nested field with a more-specific element type — requires # type: ignore[assignment] to satisfy mypy under strict = true. We carry roughly 50 of these in our codebase across the schema package.

The mypy Pydantic plugin treats field annotations as invariant under inheritance, even when the override is logically covariant (every AffectedPackage IS-A LibraryAffectedPackage).

Reproducer

from adcp.types import UpdateMediaBuyResponse as LibraryUpdateMediaBuyResponse
from adcp.types import AffectedPackage as LibraryAffectedPackage
from pydantic import Field

class AffectedPackage(LibraryAffectedPackage):
    # Internal-only fields excluded on the wire
    changes_applied: dict | None = Field(default=None, exclude=True)
    buyer_package_ref: str | None = Field(default=None, exclude=True)

class UpdateMediaBuyResponse(LibraryUpdateMediaBuyResponse):
    # Override to use the extended element type — same shape on the wire,
    # extra internal fields excluded via exclude=True. Pydantic accepts it
    # at runtime; mypy rejects it.
    affected_packages: list[AffectedPackage] | None = None  # type: ignore[assignment]

mypy --strict complains about affected_packages being incompatible with the parent's list[LibraryAffectedPackage]. There's nothing wrong at runtime — every AffectedPackage is a LibraryAffectedPackage. The issue is that mypy sees list[X] as invariant in X.

Where this hits us

We carry ~50 instances of # type: ignore[assignment] in src/core/schemas/ for exactly this pattern. Sample:

This is the recommended pattern (Critical Pattern #1 in our CLAUDE.md). It's not a code smell on our side — we're following the docs. The strictness ergonomics for adopters are the issue.

Proposed SDK shape

A few possible directions, in increasing order of impact:

A) Use Sequence[X] instead of list[X] in library response types where adopters are expected to override the element. Sequence is covariant in its element type, so Sequence[AffectedPackage] is a valid subtype of Sequence[LibraryAffectedPackage].

Drawback: callers who want .append() need a mutable type. May not work for in-place mutation paths.

B) Generic library types parameterised on extension points. The library generates UpdateMediaBuyResponse[TAffectedPackage = AffectedPackage], and adopters subclass with class UpdateMediaBuyResponse(LibraryUpdateMediaBuyResponse[AffectedPackage]): .... mypy is happy; no type: ignore needed.

Drawback: more codegen complexity; new public API.

C) Codegen extension hooks. datamodel-codegen emits # extension: list-element-type comments on field annotations that adopters typically override; a separate codegen step generates an Extended<Type> skeleton that adopters can subclass with the field overridden cleanly.

Drawback: most invasive.

Why this matters beyond us

Any Python adopter who extends library types under strict mypy hits this. We absorb 50 type: ignore lines because we're the most advanced adopter; subsequent adopters either (a) carry the same lines, (b) silence the errors with bare type: ignore, or (c) abandon the inheritance pattern in favour of duplication — which the docs explicitly warn against.

This is a strictness-ergonomics gap, not a runtime bug. Companion to #615 (nested model_dump() overrides), which is the same family of friction.

Files

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions