skip to content

Why does a type checker reject a subclass that redeclares an inherited attribute with a narrower type?

level: seniorimportance: should knowfreq 28%

answer

  1. An attribute is read and written
  2. Two directions leave only one safe type
  3. Ask who else assigns to it
  4. Class-level or per-instance is part of the contract
  5. Read-only positions may narrow

basics

~20 s

A plain attribute is both read and written through a base-typed reference, so its declared type is invariant: base code may assign anything the base's annotation allows, which a narrower subclass declaration cannot honour. Only read-only positions may narrow.

solid answer

~50 s

An attribute is not just a value you read; base-class code may also assign to it. Through a base-typed reference both operations are legal, so the declared type has to be **invariant** across an override — narrowing it in a subclass promises something the base can violate on the very next line, and widening it breaks readers. That is why redeclaring `writer: TextIO | None` as `writer: StringIO` in a subclass is an error even though the assignment runs fine. `ClassVar` adds a second rule: a name declared `ClassVar` in the base may not be redeclared as an instance variable in a subclass, or the reverse, because the two live in different places. Read-only positions are the exception — a property with no setter is only ever read, so a subclass may narrow its return type. Nothing here raises at run time; the guarantee is entirely static.

code

python · 11 lines
python
from typing import ClassVar

class BatchGeocoder:
    retries: ClassVar[int] = 3
    cache: dict[str, object] = {}

class CachingGeocoder(BatchGeocoder):
    retries: int = 5                              # ClassVar redeclared per instance
    cache: dict[str, tuple[float, float]] = {}    # writable attribute narrowed

print(BatchGeocoder.retries, CachingGeocoder().retries)

go deeper

for a junior

Know the shape of the rule: an inherited attribute keeps the type the base declared, and re-annotating it in a subclass with a different type is a type-checker error even though the code runs.

for a middle

Explain the reason rather than reciting it: the attribute is read and written, so the declared type must work in both directions. Cover the ClassVar rules and the fact that supplying a new value without re-annotating is fine.

for a senior

Demonstrate the production judgement — describe a concrete failure a narrowed attribute causes, such as cleanup logic that skips a resource it did not expect to own, and name the honest fixes: a read-only property, a generic base, or run-time validation.

for a principal

Own the boundary decision. Decide when a family of subclasses that keeps needing narrower state should be modelled generically or by composition instead of inheritance, and how strictly the codebase treats silenced attribute diagnostics.

## Why attributes behave differently from return types An override's return type may be narrowed because a return is a one-way promise: the caller only ever *reads* it. An attribute is two-way. Given `g: BatchGeocoder`, a checker allows both `x = g.writer` and `g.writer = something`, and both are legal from inside base-class methods too. A declared attribute type therefore appears in a read position *and* a write position, and a type that must be safe in both directions can only be the same type — it is **invariant**. Work through it. The base declares `writer: TextIO | None = None`, and a subclass redeclares `writer: StringIO` because that subclass always uses an in-memory buffer. Now any base method containing `self.writer = open(path, "w")` is, on a subclass instance, storing a real file object into an attribute the subclass swore was a `StringIO`. Subclass code that reasoned "a StringIO needs no closing, so my finish path can skip it" now skips closing a real file. In a long-running geocoding batch that is a resource left unclosed once per work item — invisible in a 340-case regression pack, and eventually an exhausted file-descriptor table in production. The checker refuses the redeclaration precisely because it cannot see which base method will do the assigning. Widening fails for the mirror reason: a subclass declaring `writer: object` breaks every reader that was promised a `TextIO`. ## ClassVar redeclaration `typing.ClassVar` marks a name as living on the class, not on instances. It carries two extra rules that catch real bugs. First, a checker refuses assignment to a `ClassVar` through an instance — `self.retries = 5` where `retries` is a `ClassVar[int]` is an error, because at run time that would create a shadowing instance attribute rather than change the class-level value everyone else reads. Second, the class-or-instance decision is part of the base's contract, so a subclass may not flip it. A base declaring `retries: ClassVar[int] = 3` and a subclass declaring `retries: int = 5` is rejected: code holding a base-typed reference may read `type(g).retries`, and code holding the subclass may set it per instance, and those two views cannot both be right. The reverse — an instance variable in the base redeclared as a `ClassVar` in the subclass — is rejected too, since base code assigning per instance would now be mutating shared state. Note that assigning a value in the subclass body *without* re-annotating it is fine and common; it simply supplies a new default for the inherited declaration. `typing.Final` is stricter still: a name declared `Final` in a base may not be redeclared in a subclass at all, with any type. ## The read-only escape hatch The invariance argument evaporates when the write position does not exist. A property with a getter and no setter can only be read, so a subclass may narrow its return type — the same covariance that applies to any return. The converse is an error people meet less often but should recognise: replacing a *writable* inherited attribute with a *read-only* property in a subclass is rejected, because base-typed code that assigns to the attribute would now hit an `AttributeError`. Substitutability covers what callers may do to an object, not merely what they may read from it. This gives you the practical toolkit when a subclass genuinely needs a narrower type. Expose the value as a read-only property and let the subclass narrow the getter. Or make the base generic in that attribute's type so each subclass supplies its own parameterisation. Or keep the base's wide declaration and validate on assignment. What you should not do is redeclare and silence the diagnostic — the whole class of bug it prevents is the one that surfaces far away from the redeclaration. ## Run-time reality None of this is enforced by the interpreter. Annotations in a class body are just declarations; a redeclaration in a subclass replaces the entry the class records for that name and changes nothing about how attribute lookup works. Since Python 3.14 (PEP 649/749) annotations are not even evaluated when the class is created — they are computed lazily on demand, and `annotationlib` is the standard way to ask for them in a chosen form. That makes annotations cheaper and lets them refer to names defined later, but it does not add any run-time checking: an incompatible redeclaration still produces a perfectly ordinary class whose bug only a checker, or production, will find.

  • Why may a read-only property narrow its type when a plain attribute may not?
    Because only the read position exists. A getter with no setter is a one-way promise, exactly like a return type, so covariance is safe. Add a setter and the write position comes back and the type becomes invariant again — which is also why replacing a writable inherited attribute with a read-only property is itself an error.
  • Can a subclass turn an inherited ClassVar into an instance variable, or the reverse?
    Neither. Whether a name lives on the class or on the instance is part of the base's contract: flipping it changes where writes land and who sees them. Supplying a new value in the subclass body without re-annotating is fine — that just overrides the default for the inherited declaration.
  • A subclass really does need a narrower type for an inherited attribute. What are the honest options?
    Expose it as a read-only property and narrow the getter; make the base generic in that attribute's type so each subclass supplies its own; or keep the base's declaration and validate on assignment, failing loudly. Silencing the diagnostic is the option that produces a bug far from the redeclaration.

A shared mailbox labelled "letters and parcels" cannot be relabelled "parcels only" by one household member: the postman was told letters are acceptable and keeps posting them. A pigeonhole you may only take things out of is different — nobody minds if you promise the contents are always parcels.

saying these in an interview costs you the question

  • Says an attribute may narrow because a return type may
  • Forgets that base-class methods also assign to the attribute
  • Thinks Python raises at class creation for an incompatible redeclaration
  • Believes a subclass may convert a ClassVar into an instance attribute
  • Claims a read-only property may replace a writable inherited attribute
  • Treats annotations as run-time validation

context