cast(T, something) doesn't seem to work when T is a type variable

mypy 1.14.1 does not appear to treat cast(T, a_thing) as narrowing a_thing to whatever T when T is a type variable. Is that expected behavior?

Update: In the course of composing this message, I have realized that cast wasn’t working as I expected even with cast(bool, other_thing). But you will have to endure my thought process below anyway.

What follows is not a carefully constructed example, but I hope it does enough to communicate what I am after.

from typing import Any, Generic, Optional, TypeVar, cast, Protocol
...
K = TypeVar("K")
type KeyGenerator[K] = Callable[[], K]
type Cryptor[K] = Callable[[K, bytes], bytes]

class Ind(Generic[K]):
    ...
    def __init__(
        self,
        key_gen: KeyGenerator[K],
        encryptor: Cryptor[K],
        decryptor: Optional[Cryptor[K]] = None,
        transition_table: Optional[TransitionTable] = None,
    ) -> None:
        ...
        self._key: Optional[K] = None
        self._b: Optional[bool] = None
        ....

    def encrypt_one(self, m0: bytes, m1: bytes) -> bytes:
        ...
        # We know that self._b and self._key aren't None at this point
        cast(bool, self._b) 
        cast(K, self._key).  # Fails to narrow self._key
        ....
        m = m1 if self._b else m0  # mypy is happy here
        ...
        ctext = self._encryptor(self._key, m)  # mypy is not happy here

Mypy’s unhappiness:

error: Argument 1 has incompatible type "K | None"; expected "K"  [arg-type]

Ok. I just realized when composing this that it is possilbe that the cast for the bool isn’t “working” either, but that bool | None would be fine where self._b is used. … I have just checked and that is the case.

If I add a line with foo: bool = self._b, I get a similar complaint from mypy. So this isn’t about type variables, I appear to be using cast wrong.

Until I understand what I don’t understand about cast, I will just use assertions for type narrowing in these cases, but my intuition is that there should be a way to narrow without anything having to happen at run time.

cast just changes the type a static type checker will infer for the expression just in the context of the statement that uses that expression. It doesn’t narrow the type of a variable on an ongoing basis.

You could do something like self._b = cast(bool, self._b). This would work because type checkers would see “oh some bool is being assigned to self._b so it’s no longer None” and at runtime the assignment wouldn’t change anything.

But like you said, you can also just assert and in fact, I recommend this. With assert you’ll find out if something goes wrong, but with cast neither the type checker nor the runtime will alert you to even silly mistakes.

5 Likes

cast just changes the type a static type checker will infer for the expression just in the context of the statement that uses that expression. [Emphasis added]

Ah. Thank you. So another way I could use cast would be

     ctext = self._encryptor(cast(K, self._key), m)

So as you said, I can more simply use assert for the type narrowing in these sorts of cases. But if I am going to do something that happens at run time, I might as well check the type and raise some “Shouldn’t happen” exception.

In my post I wrote

Changing intuitions

[My] intuition is that there should be a way to narrow without anything having to happen at run time.

The more I think about that, the more my intuition is changing with regard to narrowing that persists outside of a particular expression. While type checking is static (and so I thought that there should be some static narrowing that applies to a variable on an ongoing basis), I realize that that really would be just like sprinkling # type: ignore comments around.

We do want the type checking to alert us about introducing unexpected behavior of the program. And the way that I initially wanted cast to operator would have made it too easy to inadvertently conceal problems. Anassertion is a far better expression of my confidence that the type is as I think it is than just telling the type checker that I think I know what is going on. And run-time testing will also help if some other parts of the code change in ways that break my assumptions.

1 Like

It’s funny you say this, since assert can disappear from runtime (py -O), unlike cast.


We know that self._b and self._key aren’t None at this point

You can circumvent this in a few ways:

  1. give the variables some meaningful defaults instead of None.
  2. accept the value for _key/_b during initialization, so the instance is never in a state where encrypt_one could be called with the values being None. [1]
  3. if encrypt_one is called from another method which sets the variables prior, it could accept the values as parameters instead.

  1. if this is ensured in a piece of code you’ve removed from the snippet, how does it happen? ↩︎

What is the recommended assertion spelling for type forms that aren’t actual runtime types (and hence won’t be accepted by isinstance)?

One general option is to write a TypeIs predicate function that does the best job of a runtime check it can. For structural types like TypedDict or Protocol, type checkers will often let you narrow with in or hasattr. And there’s always # type: ignore or the x = cast(typ, x) hammer I mentioned earlier.

This situation mostly just doesn’t come up for me though (especially if you don’t care about strictly static type checking code that is doing runtime type checking). Happy to offer an opinion on a code snippet though!

I’m currently using cast in a generic caching method that accepts a type and returns a cached instance of that type from a dict keyed by an attribute of the type.

The dict is nominally typed as the base class used in the bound type variable.

And in writing that out, I realised assert isinstance(val, cls) should work (since I have a reference to the actual runtime type).