Skip to content

← Precise Type Hints with Literals and Generics step 14 of 26

Medium Framework

Why a list[Dog] Is Not a list[Animal]

The starter's summarise counts values across a batch of records, and its signature takes list[dict[str, object]]. Two callers hold records of a narrower type, a list[dict[str, str]] and a tuple[dict[str, int], ...], and mypy rejects both calls. Fix the signature, not the callers.

Why a list of dogs is not a list of animals

Say Dog is a subclass of Animal. Then Dog is a subtype of Animal, which means a Dog can go anywhere an Animal is expected. A list[Dog] cannot go everywhere a list[Animal] can, though:

def adopt(pets: list[Animal]) -> None:
    pets.append(Cat())

dogs: list[Dog] = [Dog()]
adopt(dogs)
error: Argument 1 to "adopt" has incompatible type "list[Dog]"; expected "list[Animal]"
note: "list" is invariant -- see https://mypy.readthedocs.io/en/stable/common_issues.html#variance
note: Consider using "Sequence" instead, which is covariant

A function that takes list[Animal] may append any animal, such as a cat. If mypy allowed the call, your list of dogs would hold a cat, and the next dogs[-1].bark() would crash. So list is invariant, which means list[Dog] cannot be used as list[Animal], or the other way round. dict is invariant in its value type for the same reason, because you can store a new value in it.

Sequence from collections.abc has no append and no item assignment. Sequence is covariant, which means it follows the element type. Sequence[Dog] can be used as Sequence[Animal], because code can only read from it. Mapping is the read-only version of dict. It is covariant in its value type, so Mapping[str, str] can be used as Mapping[str, object]. Its key type stays invariant.

The name for these rules is variance. A third kind, contravariance, goes the other way and applies to function parameters. Later exercises in this track cover it.

What to write

from collections.abc import Mapping, Sequence

def summarise(records: Sequence[Mapping[str, object]]) -> dict[str, int]:
    ...

Change both layers of the type. Sequence[dict[str, object]] still fails, because dict is invariant in its values. Keep the two constants and their calls in solve as they are, with no casts, so mypy checks your signature against them.

For each key, summarise counts how many records hold a value other than None:

  • Visit the records in order, and each record's keys in order.
  • Only None counts as absent. 0, False, "" and [] all count.
  • List the keys in the order you first see them, even when that first value is None.
  • A key seen only with None appears with the count 0.
  • Return a new dictionary, and leave the records unchanged.
summarise(({"a": 0, "b": None}, {"a": None, "b": False}))  # {"a": 1, "b": 1}
list(summarise(({"z": None}, {"a": 1})))                    # ["z", "a"]

What solve returns

The tests send each record as a list of [key, value] pairs, because the site's JSON storage does not keep the key order of an object. Rebuild each record with {str(key): value for key, value in record}.

If two keys become the same string, the dictionary keeps the last value at the first key's position, and summarise counts that entry once. For example, [[1, "first"], ["1", None]] becomes {"1": None}, which counts as 0.

Call summarise on the rebuilt records, and return a tuple of three lists:

  1. The keys, in first-seen order.
  2. Their counts, in the same order.
  3. The values of summarise(NARROW_RECORDS), then the values of summarise(WIDE_RECORDS). These are always [2, 1, 2], even for empty input.
solve([[["a", None], ["b", 0]], [["a", 1]]])  # (["a", "b"], [1, 1], [2, 1, 2])
solve([[["a", 1], ["a", None]]])              # (["a"], [0], [2, 1, 2])
solve([])                                     # ([], [], [2, 1, 2])

The keys and counts come back as two lists so that the tests can check their order. See mypy's guide to variance for more examples.