Skip to content

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

Hard End-to-End

Modernisation kata: inventory.py, 2019 to 2026

This is week one on a legacy codebase, compressed into one module.

You are given inventory.py as it was last touched in 2019 and asked for the same behaviour, expressed the way the language works now. Not a rewrite — a modernisation. Every observable output must be identical; the difference is that afterwards, a whole class of mistake becomes impossible to make.

What is wrong with the module you are given

Read the starter before you read this list. Then check yourself against it.

  • typing.List / Dict / Optional everywhere, and a Record = Dict[str, Any] alias that is the Any boundary in disguise. -> builtin generics, X | None, and a real structure.
  • A module-scope TypeVar("T", covariant=True) used in a parameter position. That is not merely old spelling: it is unsound, and it is why a naive PEP 695 conversion cannot be done in one step. A covariant parameter in an input position has to be split into read and write halves before the conversion is even legal.
  • Quoted forward references and # type: comments — pre-3.0 syntax kept alive by inertia.
  • Magic string flags. "active" and "discontinued" as bare constants, compared with ==, with "activ" a typo away from a silent empty result set. -> a Literal type plus a narrowing parser.
  • An unbranded int/str id. A SKU is a str, a name is a str, and nothing stops you passing one where the other belongs. -> NewType.
  • list[T] parameters that should be Iterable[T] / Sequence[T].
  • A bare except Exception: return None around parsing, which turns every malformed record into an indistinguishable None and loses the reason.
  • json.loads returning Any, laundered into a “typed” structure. This passes plain --strict silently — it is the single most valuable thing to fix and the one no gate catches for you.

The task

def solve(
    raw: list[dict[str, object]],
    status: str,
    minimum: int,
) -> tuple[list[str], int, list[str], str]:

Parse each raw record into an immutable Item, collecting failures rather than discarding them. Then filter by status and by quantity >= minimum, and return:

  1. the matching SKUs, sorted;
  2. their total quantity;
  3. the parse error messages, in input order;
  4. f"{describe(status)}:{first_sku_or_none}".

The parse rules, and the exact error messages, are contract:

condition message
sku missing, not a str, or empty bad sku
name missing or not a str bad name
quantity a bool, not an int, or negative bad quantity
status not one of the two bad status: <repr>

Checked in that order, so a record with two problems reports the first.

describe maps active to "in catalogue" and discontinued to "retired", and closes its match with assert_never. The fourth element uses "none" when nothing matched.

The shape to aim for

Roughly: a Status literal alias and a STATUSES tuple; a Sku NewType; a frozen slotted Item dataclass; a ParseError subclassing ValueError; a parse_status(raw: object) -> Status that narrows or raises; a parse_item(record: Mapping[str, object]) -> Item; a PEP 695 first_or[T](items: Iterable[T], default: T) -> T; and a @final Inventory whose methods take Iterable/Sequence and return list.

Note parse_item takes a Mapping, not a dict — it only reads.

The traps built in

bool is an int subclass. A record with "quantity": true must be rejected. isinstance(quantity, int) alone accepts it and silently stores 1.

Falsy is not missing. quantity: 0 is a valid record and must survive. if not quantity: raise would eat it.

record.get("sku") is object, not str. Every field needs an isinstance narrowing before use, and that narrowing is the schema. Once you have written it, Any has nowhere to enter the module — which is the actual deliverable. Passing --strict was never the goal; having no unchecked value anywhere in the file is.

A rejected record must not consume anything. Errors are collected in input order and the valid records are unaffected by their neighbours.

Gates

mypy --strict plus --warn-unreachable and --extra-checks. Beyond that, the module should have no Any, no cast, no # type: ignore, no bare except, no typing.List, and no Generic.

Loading visualization…