I use the new typestatements to make type aliases. For example, I have in my code a PathLiketype that represents the possible arguments to pathlib.Path:

import os

type PathLike = str | os.PathLike

Now, I wanted to check whether a given variable is PathLike, so I used isinstance(p, PathLike), but it didn’t work since isinstancerequires a type, a tuple of types, or a union of types:

print(isinstance(‘foo.txt’, PathLike))

TypeError: isinstance() arg 2 must be a type, a tuple of types, or a union

This is weird since isinstancealready support type unions, so the old style of defining type aliases:

from typing import TypeAlias
import os

PathLike: TypeAlias = str | os.PathLike  # Or just `PathLike = str | os.PathLike`

works with isinstance. The only solution is to use PathLike.__value__which gives me the underlying union, but it means that now old-style type aliases don’t work. Why doesn’t isinstance work with the new type statement, and what’s the recommended way to make a type alias and use it for type checking at runtime?

2 Likes

isinstance doesn’t work with type aliases because it doesn’t do type checking but class instance checking. I.e. it doesn’t check whether a value is of some type but whether an object is a member of a particular class. Simple types like “the type of all integers” do coincide with classes like int, but in general a type like list[int] is much more complex than just a collection of all objects that were created by a particular class.

The fact that isinstance is able to deal with (non-empty) unions is more or less a historical accident and often considered to be a bad decision. It came about mostly because it was already able to check whether an object is a member of one of a tuple of classes, which largely corresponds to checking whether a value is in the type of the union of those classes.

So in general, there is no way to make a type alias and have it work with isinstance. Some simple aliases like type new_name = int or type new_name = int | str can be made to work when you use the old syntax new_name: TypeAlias = int | str instead, but that also has other disadvantages. There also are many cases where isinstance just cannot do what you’re asking of it, e.g. if your type alias ever involves generic arguments. I’d recommend structuring your code in such a way that isinstance only ever gets classes as arguments.

6 Likes

PathLike is an oft-seen and really useful pattern - it’s a strong candidate for better support, in my opinion.

I didn’t know Union’s worked with isinstance before today though, so thanks for posting this.

>>> PathLike = str | os.PathLike
>>> isinstance('foo.txt', PathLike)
True

Not knowing anything about why it was considered a bad decision, this seems pretty neat to me! What extra benefit does the type key word add, or what requirement does it fulfill for you?

Doing this can lead users to believe that isinstance works with any kind of object used for type hinting, which is not the case. Therefore I think it’s fair to say that the addition of that possibility is controversial.

I suppose some linters may error out on the <name>: TypeAlias = ... way of defining TypeAliases, so it makes sense if that’s the reason they want to use the new syntax.

This has come up before and I’ve come around to thinking that we could sensibly add this. A complication is that the value of a type alias is lazily evaluated, so using it in isinstance() might fail and raise an error.

If someone implements this feature, I will review it and we can consider adding it in 3.15.

1 Like

It’s considered bad because not all types are classes. As a result, isinstance only works with some type aliases, which isn’t immediately obvious. Expanding on your example:

>>> import os
>>> from pathlib import Path
>>> StrPath = str | os.PathLike[str]
>>> isinstance("foo.txt", StrPath)
True
>>> isinstance(Path("foo.txt"), StrPath)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: isinstance() argument 2 cannot be a parameterized generic
2 Likes

OTOH, I think the modern type aliases are a good reason to just deprecate the old TypeAlias and Union support in isinstance.

The current isinstance + typing situation is already confusing. Ruff literally promoted the Union style for isinstance checks and then flipped back to recommending the tuple style. That kind of churn is exactly why this behavior shouldn’t stick around. It just adds noise and inconsistency for everyone.

The bigger issue here is that the current workaround is unnecessarily verbose, for example typing.get_args(Alias.__value__). It might make sense for TypeAliasType to provide a method that returns a tuple suitable for passing directly to isinstance.

I’d be happy to see the legacy TypeAlias and Union support marked for deprecation now, with full removal in about five or so years.

1 Like

Since lazy evaluation is mainly intended to reference classes that haven’t been defined yet, this shouldn’t be a problem most of the time, since isinstance() will almost always be called after all classes in a module have been defined.

If someone implements this feature

A pure-python implementation would be as simple as:

class TypeAliasType:
  ...
  def __instance_check__(self, inst):
    return isinstance(inst, self.__value__)
  # likewise for subclass check

There’s nothing to deprecate here, the use of TypeAlias is completely transparent at runtime outside of the original definition site[1], so it’s really no different from a regular assignment at runtime. isinstance has no clue whether or not the object you passed it was originally defined in an assignment annotated with TypeAlias.

Unless you mean type checkers should start reporting an error if you try to use a TypeAlias in an isinstance call. In which case I would agree with you, if we wound the clock back to when it was first introduced, given its footgun nature if the TypeAlias contains a forward reference. But at this point it’s probably more disruptive to change it and there are different ways to avoid this footgun, like type checkers actually keeping track of whether or not a type is available at runtime or not.

Deprecating Union in isinstance is similarly disruptive, so it’s probably too late to change it at this point. I’d prefer it to not be a thing, but I can also see the ergonomics argument, if you allow it, you don’t have to manually keep your isinstance calls in sync with your type unions.


  1. if you happen to know its name ↩︎

I don’t think that adding this would be an improvement. Type Aliases aren’t types at runtime, they are intentionally wrappers that defer many things. Implicitly unwrapping them and treating them as runtime type objects creates the same muddy waters between the differences between values and type annotations that people have been working to avoid and better differentiate between.

3 Likes

If i had a nickel for every project I’ve seen include the following code:

JsonPrimitive = NoneType | bool | int | float | str
type JsonType = JsonPrimitive | list[JsonType] | dict[str, JsonType]

I’d have 2 nickels, which isn’t a lot but I hate it. The former can’t be a type statement because it’s used in isinstance and the latter has to be a type statement because it’s recursive and therefore uses forward references.

2 Likes

Yep, that’s what I meant.

I’m also aware that deprecating either is disruptive, but I don’t want to keep the status quo indefinitely. Linters already encourage the use of newer and better typing constructs, which should help ease the transition. To reduce the impact, the deprecation period could be made longer than usual, for example around six years from the minimum Python version that supports the new constructs (3.12), rather than the standard two years before removal.

Not sure it’s a good idea to deprecate, TypeAliasType is better in general and has preferable semantics, but as this thread shows it cannot replace TypeAlias. Needing to make two identical definitions for the same thing, one for type checkers and one for runtime is not really a reasonable solution. As mentioned another approach is using __value__, but that’s not good either - it’s incredibly verbose, and I don’t believe any type checkers will understand it, so narrowing won’t work.

In regards to the problem of most types not being suitable for isinstance(), is that a problem of the aliases themselves? You’d have the same potential confusion when using them directly. There’s precedent with this sort of behaviour already - tuples are hashable only if their elements are, that doesn’t cause confusion.

Tangentially, another behaviour it’d be nice to have is for aliases to define __mro_entries__() so you can inherit through them. That seems to me like a fairly type/class-related operation, and even more types allow that compared to instance checking.

1 Like

We can somewhat easily update the spec for type checkers to implement the handling of __value__, so that shouldn’t be too much of an issue.
To make some types work with isinstance, perhaps a typing.type_is(...) (not TypeIs), or the already existing assert_type could be used.

3 Likes