Should `type(x)` perform type erasure for generics?

Consider the following:

x: list[int] = [1]
reveal_type(type(x))
type(x)('abc')

Now mypy (playground), pyright (here) and pyre (here) show type[list[int]] as revealed type and reject the call. pytype (no playground available) also reveals Type[List[int]], but accepts the following call. Seems like there’s some consensus regarding this, and only pytype doesn’t follow it - but was it a deliberate decision?

IMO last line should really mean “please build a list from string” rather than “please build a list[int] from string” - why is the generic type not erased there?

What am I missing? Looks like type(x) should be type-erased: it is indeed safe to construct instances parameterized by a different generic type, and all the necessary mechanics to fall back to the original constructor and check the call do already exist. I’d expect the type checker to accept such construct, because semantically type(x)(...) is “please treat this as a new constructor call for the type”. Since type(x) does not contain any additional type information at runtime, why should it be restricted to the same set of parameters? type(list[int]()) is still <class 'list'>, it doesn’t know anything about previous generics specified.

I’m raising this as a public question, because type(x)(...) is a pretty common idiom for “build another instance of same type”. Inspired by this StackOverflow question.

This problem is not addressed directly in the typing spec, and probably deserves some place there.

At runtime type(x) is just list, not list[int], so I think that’s ideally what type checkers should infer too.

The type checkers you flag should therefore probably change their behavior.

For both mypy and pyright it is possible to construct an example where the type checker behaves incorrectly because of this behavior:

from typing import reveal_type

def g(x: list[int] | str):
    if type(x) is list[int]:
        reveal_type(x)  # list[int] (should be Never)
    else:
        reveal_type(x)  # list[int] | str
1 Like

Oh wow, I agree that type(x) is Something[Parametrized] should be unconditionally a typecheck warning right away (like mypy’s comparison-overlap code) and Never in branch - I didn’t even consider such case. Thanks!

I think the current behavior of pyright, pyre, and mypy is arguably correct. Changing this behavior would likely create more problems than it solves.

Consider the following:

from typing import Iterable

class IntList(list[int]):
    def __init__(self, iterable: Iterable[int], /) -> None:
        ...

def func(x: list[int]):
    return type(x)(["a"]) # Violates the type of `IntList`

func(IntList([]))

There are many examples where the runtime erases types but a type checker does not, so I think it’s a weak argument that type checkers must match the runtime behavior in this case.

If we were to change the typing spec to indicate that type checkers must erase generic types for type(x), the resulting type would presumably need to replace all of the type arguments with Any (and ... for ParamSpecs, *tuple[Any, ...] for TypeVarTuples). That’s probably not what most developers would expect, and it would mask type errors that are caught today. It also wouldn’t catch the error in Jelle’s example above.

2 Likes

Regarding the IntList example, the underlying problem there is perhaps the fact that __init__ is not checked for LSP violations, which makes any use of type(x)(...) arguably unsafe. It is also possible to come up with examples that are unsafe under the current behavior (e.g., if IntList.__init__ does not accept an iterable).

I think in my example, the mistake type checkers are making is that they accept a parameterized generic on the right-hand side of a type(...) is ... comparison, which is incorrect since type() never returns a parameterized generic. That’s perhaps a separate issue from the problem of what type() itself returns.

1 Like

Yeah, thank you - that answers my question fully. I didn’t consider specialization upcasting: indeed, we can’t be sure that something typed as list[int] can be parameterized by any other type. Your IntList is the reason why type erasure is not allowed here.

We could likely come up with a better something here without needing to check __init__ for LSP violations if there was a way to copy signatures of callables. (I’d prefer we fix this, but that seems unlikely…), and improve on that slightly with intersections added to it.

when people write type[T], it’s usually to have something callable with the same signature as T, that results in T.

perhaps type(x) could be typed to accurately reflect this intent with more developments in the future.

1 Like