Skip to content

← Data Modelling and Invariants step 17 of 25

Medium Primitives

kw_only, KW_ONLY and the inheritance ordering wall

Insert one field into a shared base class and every positional call site in the codebase now binds arguments to different fields. If the types happen to line up — two strs, an int and a bool — there is no type error and no runtime error, just wrong data, written to your database, forever.

That is the real reason to make record types keyword-only. The ordering rule is the mechanism that forces the issue.

The wall

A generated __init__ is an ordinary function signature, so it obeys the ordinary rule: a parameter without a default cannot follow one with a default. Applied across inheritance, that becomes a permanent constraint:

@dataclass
class Resource:
    id: str
    created_at: str = "1970-01-01T00:00:00Z"

@dataclass
class Bucket(Resource):
    name: str      # TypeError: non-default argument 'name' follows default argument

Once a base class has one defaulted field, no subclass may ever add a required one. In a shared Resource/Entity/BaseModel base this shows up about three months in, and the usual “fix” is the worst one available: name: str | None = None plus a runtime if self.name is None: raise. You have now moved a compile-time guarantee into a runtime check, and mypy will make you handle None at every single use site forever.

The two real fixes

kw_only=True on the decorator: every field becomes keyword-only, so __init__ has no positional parameters at all, so the ordering rule cannot bite. Fields still have an order (reverse MRO, as always) — it just no longer constrains anything.

The KW_ONLY sentinel, for finer control:

@dataclass
class Point:
    x: float
    y: float
    _: KW_ONLY
    label: str = ""

Everything after the marker is keyword-only. One per class; its name is ignored (_ by convention); it is not a field.

The second-order effect nobody mentions

Keyword-only fields are excluded from __match_args__. So a fully keyword-only dataclass has __match_args__ == (), and structural pattern matching sees no positional prefix at all:

match bucket:
    case Bucket(bucket_id):        # TypeError at runtime: no positional sub-patterns
        ...
    case Bucket(name=n):           # fine — keyword patterns still work
        ...

That is a real trade, not a bug: you gave up positional construction, and positional matching goes with it. Keyword patterns are the better habit anyway, for exactly the same reason keyword construction is.


Your task

The module defines a three-level hierarchy: Resource (with id, a defaulted created_at, and a required owner declared after it), Bucket(Resource) adding a required name, and VersionedBucket(Bucket) adding a required version. As written it does not even import.

Make it work — without reordering fields, without giving anything a default it should not have, and without the | None = None + runtime-check workaround. Then implement:

def solve(target, resource_id, owner, name="", version=0) -> dict[str, object]:

returning:

  • "field_order"[f.name for f in fields(obj)], in the documented reverse-MRO order;
  • "match_args"list(type(obj).__match_args__);
  • "repr"repr(obj);
  • "positional_error""TypeError" if constructing the class with a single positional argument fails, "" otherwise (the helper is written for you).

Types. mypy catches this class of bug statically in both directions: the ordering violation is “Attributes without a default cannot follow attributes with one”, and a positional call to a keyword-only dataclass is “Too many positional arguments”. That second one is why _positional_error has to launder the class through a Callable[..., object] to attempt the call at all.