Skip to content

← Interfaces with Protocols and ABCs step 18 of 26

Medium Framework

Validate Records with TypeIs Instead of cast

The type hints track showed how TypeIs narrows a value in both branches of an if. It also showed why the check must return True for every value of its type. Here you use it to check data that comes from outside your program, such as decoded JSON, instead of casting it.

The starter reads each record like this:

payload = cast(UserRecord, record)

typing.cast(T, value) tells mypy to treat the value as T. It checks nothing and does nothing at runtime. So mypy accepts the starter, and the starter crashes on the first record that has no roles key.

What to write

Keep the UserRecord TypedDict as it is, with name: str and roles: list[str]. Add this function:

def is_user_record(value: object) -> TypeIs[UserRecord]: ...

It returns True when all of these hold, and False otherwise:

  • The value is a dict.
  • Every key is a string, including any extra keys.
  • name is present and is a str.
  • roles is present and is a list whose items are all strings. A tuple does not count.

Extra keys are fine, whatever their values. An empty name, an empty roles list and repeated roles are all valid. A False result from a TypeIs check tells mypy the value is not a UserRecord, so rejecting any of these would make mypy wrong about the value in the else branch. Leave the input unchanged, and do not convert numbers to strings or fill in missing keys.

Then change solve(records) to return one string per record, in order:

  • For a valid record, its name in uppercase, a colon, and the number of roles.
  • For anything else, "invalid".

Use if is_user_record(record):, so mypy narrows record to UserRecord inside the if, and remove every cast.

Example

solve([{"name": "ada", "roles": ["dev", "dev"]}, None])
# ["ADA:2", "invalid"]

Repeated roles count separately. An empty list of records gives an empty list.

The typing documentation's type narrowing guide compares TypeIs with TypeGuard.