Skip to content

← The Type System as a Design Tool step 7 of 24

Easy Primitives

ClassVar, field(), and the counter that must reset

ClassVar is the only annotation in this track that changes what a program does, not just what a checker believes.

Everywhere else, an annotation is a claim the checker verifies and the interpreter ignores. Inside a dataclass, ClassVar is read by @dataclass itself: a ClassVar field is excluded from __init__, from __repr__, from __eq__, and from fields() entirely. Remove the annotation and the same line becomes a constructor parameter. Add it and the parameter disappears. Same code, different API.

And the bug it prevents is one of the quietest in Python:

class Registry:
    entries: ClassVar[dict[str, int]] = {}

    def __init__(self) -> None:
        self.entries = {}     # <- shadows the class attribute, per instance

The shared-state feature silently stops being shared. Every instance gets its own dict, nothing raises, nothing logs, and the symptom shows up three services away as “the cache never hits”. With the ClassVar annotation present, mypy reports that assignment as a misc error — “cannot assign to class variable via instance”.

The task

@dataclass
class Task:
    PRIORITIES: ClassVar[tuple[str, ...]] = ("low", "normal", "high")
    _next_id: ClassVar[int] = 1

    name: str
    priority: str = "normal"
    tags: list[str] = field(default_factory=list)
    id: int = field(init=False, default=0)

    def __post_init__(self) -> None: ...


def solve(
    names: list[str],
    priorities: list[str],
    tag: str,
) -> tuple[list[int], list[str], list[str], list[str], list[str]]: ...

__post_init__ validates priority against PRIORITIES, raising ValueError for anything else, and then assigns id from the class counter and increments it. Validation comes first, so a rejected task does not burn an id.

solve resets Task._next_id to 1, zips names with priorities, constructs a Task for each pair, collects the ones that raise, appends tag to the first successfully created task’s tags, and returns:

  1. the ids of the created tasks, in order;
  2. [f.name for f in fields(Task)];
  3. the first task’s tags;
  4. the last task’s tags;
  5. the names that were rejected, in order.

When no tasks were created, elements 1, 3, 4 are empty lists.

What each returned element proves

fields(Task) is ["name", "priority", "tags", "id"]. PRIORITIES and _next_id are absent — that is ClassVar doing its job. id is present, because field(init=False) keeps it a field but removes it from __init__.

The ids are 1, 2, 3, ... — a mutable class-level counter, mutated through the class (Task._next_id += 1), not through self. Writing self._next_id += 1 would read the class value and then create a per-instance attribute holding value + 1, and every task would get id 1 forever.

The first task’s tags grew and the last task’s did not. This is field(default_factory=list). A bare tags: list[str] = [] is a shared mutable default; @dataclass refuses it outright with ValueError: mutable default <class 'list'> for field tags is not allowed, which is one of the few places the stdlib protects you from yourself. (Worth knowing for library comparisons: attrs does not — it silently shares the list.)

The reset, and why it is in the problem

The harness runs every test case against one execution of your module. A class attribute you mutate keeps its value into the next case, so without Task._next_id = 1 at the top of solve, the second case starts counting from wherever the first one stopped and the answers stop being reproducible.

That is not a quirk of the grader. Module-level and class-level mutable state behaves exactly this way in any long-lived process, which is why the first question in review of a class counter is “what resets it, and who owns the reset?”