No, that’s how mypy protects itself against reassignment of the variable to a subtype, which would break the definition of Json, since it depends on both JsonList and JsonDict. Generally mypy only allows you to use a symbol as either a type or a value expression. The only time it allows you to use it as both, is if you leave the variable unannotated or annotate it with TypeAlias.
As for what’s inferred: That’s actually not a bug either. mypy just happens to display type[...] differently based on whether it was inferred or explicitly annotated. What you see revealed there is the constructor. Which is correct, since both list and dict have overloaded constructors.[1]
So not annotating the variable or keep using TypeAlias is the correct thing to do here, if you want the flexibility of using a name and its corresponding expression as both a type and value expression.
If your still want to annotate type for JsonList and JsonDict in mypy you can switch to a different definition[2] although you will not be able to use JsonList and JsonDict in type expressions this way:
or to put it in different terms, the type will still pass as type[...] in places that require it, even though it’s revealed as something looking like Callable[...]↩︎
I would even get rid of JsonScalar and fold it into the definition of Json unless you have an explicit use for it ↩︎
Wouldn’t another way to guard against this just be to not allow reassignment of a variable (which I believe is in any case not allowed by mypy)?
Generally mypy only allows you to use a symbol as either a type or a value expression. The only time it allows you to use it as both, is if you leave the variable unannotated or annotate it with TypeAlias.
We propose to deprecate the existing typing.TypeAlias introduced in PEP 613. The new syntax eliminates its need entirely.
Clearly, this is a use case one could previously explicitly type using TypeAlias that is no longer supported by the type system. However, this is not stated anywhere in the docs (AFAIK).
This contradicts the DRY (Don’t Repeat Yourself) principle. It kind of works here, as JsonList and JsonDict are relatiely simple, but they could have been very complicated, which are typical scenarios when I would have brought forth a TypeAlias to explicitly denote a type assignment.
The only solutions to my example then seems to be either to not type JsonList and JsonDict or to duplicate their definitions. Both are unsatisfactory to me, but of the two, not typing would be my clear preference.
The main problem with all of this is not the new type syntax. I definitely think it is a clear improvement! It seems to fix issues that were previously more cumbersome, and I suspect the more strict definition helps.
The problem is that the previous behavior of TypeAlias is exactly what I believe many developers (including me) would think of as a “type alias”. It is even clearly defined in PEP484:
Type aliases are defined by simple variable assignments:
Url = str
A definition which is reiterated in the motivation of PEP 613 (sorry no link, as I am only allowed to post two links as a new user…):
Type aliases are declared as top level variable assignments. In PEP 484, the distinction between a valid type alias and a global variable was implicitly determined: if a top level assignment is unannotated, and the assigned value is a valid type, then the name being assigned to is a valid type alias.
The new “type alias” is something else that have taken over the definition without explicitly stating so, while deprecating the earlier, and to me more “Pythonic” use of the term. Unannotated top level variable assignments to valid types are no longer “type aliases”, and the unannotated use is now preferred in certain scenarios.
In my mind, this is a regression of the typing system.
No, you can break this even in a single statement:
class MyList[T](list[T]):
pass
type Json = JsonScalar | JsonList | JsonDict
type JsonScalar = None | int | float | str | bool
JsonList: type[list[Json]] = MyList[Json]
JsonDict: type[dict[str, Json]] = dict[str, Json]
Now your type alias and the runtime type constructor are no longer the same type, but it would pass type checking, if mypy didn’t disallow a reference to a variable annotated with type[...] in a type expression.
Other type checkers like pyright go further than mypy and don’t stop at the annotation when analyzing uses of a symbol, they also analyze the value expression and allow things to pass if the use is consistent. mypy only takes into account the annotated type, so it can’t assume that using that variable as a type is safe.
reassignment is also allowed, as long as the type is assignable, it’s redeclaration which is disallowed.
The only place where the new semantics of TypeAliasType are problematic, is when you want to directly use a type alias as a constructor[1]. Runtime type libraries need to understand TypeAliasType anyways, so for them the semantics should be unproblematic, since they can just get to the constructor using __value__, if they need it.
TypeAliasType is a strict improvement over TypeAlias, exactly because it’s no longer transparent. You actually now know there is a type alias, which you didn’t know before, because it was just a regular assignment that resulted in whichever object the type expression would evaluate to.
I understand and can empathize with the frustration of people that used TypeAlias on types they wanted to use in isinstance checks and as constructors and now pyupgrade/ruff replaces that with a type statement and their code breaks. But TypeAlias was never really meant to be used that way, there just was no good reason to explicitly disallow it, since it causes no runtime issues.
TypeAlias is for when you need to signal that the RHS is a type expression. If you want to use it as a value expression you should annotate it with type, if you want to use it as both, you should leave it unannotated.
I’ll try. The real scenario for the JSON types is for recursive definition of pydantic models. However, the real code in that case included a number of hacks to avoid limitations of the pydantic library and is as such too complicated to be relevant here. So I’ll provide another simpler example instead:
from typing import TypeAlias
from pydantic import RootModel
ListOfIntsModel: TypeAlias = RootModel[list[int]]
ListOfListsOfIntsModel: TypeAlias = RootModel[list[ListOfIntsModel]]
a = ListOfIntsModel([1, 2, 3])
b = ListOfListsOfIntsModel([[1, 2, 3], [4, 5, 6]])
ListOfIntsModel is here used both for instantiating a pydantic model and as a type hint to define ListOfListsOfIntsModel. TypeAlias works to type hint this use, but the type syntax does not fit the bill.
Note: The instantiations in the last two lines cause mypy to fail with List item 0 has incompatible type "list[int]"; expected "RootModel[list[int]]". There is a pydantic.mypy plugin that should have fixed this. Unfortunately it does not seem to work for this example, probably due to a bug/missing feature. This is in any case irrelevant for the discussion in this context, as the code runs and is an uncontroversial use case for pydantic.
your example there that claims to break it is type-safe. Subtyping is part of the language and part of the type system. The only issue which could arise from it is if the subtype had an LSP violation in it’s constructor, which is something the type system shouldn’t allow, but does. The type system doesn’t require (nor should it) that a value expression is of the exact type it is annotated with. mypy’s behavior here isn’t consistent with the type system’s definitions or those of the language at runtime.
That’s quite literally what they are supposed to do, there’s a whole section in the typing specification about determining assignability.
So, my prior message is wrong because python has runtime typecheckers as well.
However, I would still say that mypy’s handling of this and changing the type incorrectly is wrong. Pyright rejects the example directly instead:
with the message “Variable not allowed in type expression”, it doesn’t require changing the type a user provided to be incompatible with itself.
As for the reason this is broken by the existence of runtime type checkers, the actual ideal here is that type alias statements that contain a variable would have a type based on use as a value vs use as a type expression, however, runtime type checkers would be unable to participate in that as the annotation of JsonList/JsonDict here are not preserved at runtime.
mypy rejects that example as well. My example was purely to illustrate why mypy emits an error for the use of JsonList and JsonDict in the RHS of type Json here:
type Json = JsonScalar | JsonList | JsonDict
type JsonScalar = None | int | float | str | bool
JsonList: type[list[Json]] = list[Json]
JsonDict: type[dict[str, Json]] = dict[str, Json]
Since mypy doesn’t consider the variable’s value when checking the use of an explicitly annotated variable[1], it has to assume that someone could have done something like in my example, which definitely should error, and even pyright agrees with mypy there[2].
So mypy is more strict than pyright to cover its own rear, since it performs a more shallow analysis in the presence of explicit type annotations. If you want to force mypy to do the same amount of work as pyright does here, to figure out that this is indeed safe-ish, then you need to remove the explicit annotation.
although you could presumably break pyright in the “safe” version by modifying the symbol across module boundaries, since the evaluation of type statements is deferred, you could smuggle an incorrect type in this way ↩︎
Sorry, it looks like pyright actually does exactly the same as mypy here in both examples. The error message for pyright is just a little bit less confusing than the one mypy uses.
See my new pydantic example for a case where the semantics of TypeAliasType would be problematic. It would be very cumbersome to use __value__ every time one wanted to instantiate a ListOfIntsModel.
If that is the consensus, then I find the definitions of type aliases in PEP484 and PEP613 highly confusing. To me, they clearly state that an assignment to a valid type IS a type alias. It seems to be the very definition!
To me that feels like a regression of the type system. I now have to stop typing something I had a valid type for in 3.10 and 3.11. Also, it feels counterintuitive that I can use a class in both contexts, but not a variable that refers to that class (or what I would have called a type alias), e.g.:
class A:
pass
B: type[A] = A
a: list[A] = [A()]
b: list[B] = [B()]
To expand on this example, currently the three different ways to define B is then either type[A] as above, or to use the new type alias syntax:
class A:
pass
type B = A
a: list[A] = [A()]
b: list[B] = [B()]
Or not annotate at all:
class A:
pass
B = A
a: list[A] = [A()]
b: list[B] = [B()]
The last code snippet is the only one that passes static type checks. I cannot see how you could expect regular Python developers to both understand the difference between these three and then agree that the last syntax is the correct one! There is nothing intuitive about this.
I think terminology is what’s causing confusion for you here, the term type is very overloaded in Python. The builtin Python object type is not the same as a type in the sense of the Python type system, which is a much broader term.
So, yes, anything that is considered a valid fully static or gradual type by the type system is a valid assignment target for a type alias, but the reverse is not true, not every type alias is a valid runtime type object[1].
I think it’s helpful to think about the type and the value of the expression separately. Type alias means it’s an alias for the type described by the expression, but not necessarily that it’s an alias for the value, which would be required to use it as e.g. a constructor. So a regular assignment is something broader, it’s an alias for a value and sometimes it also describes a valid type, so it can be used inside type expressions.
The fact that an assignment annotated with TypeAlias behaves exactly like a regular assignment at runtime, is entirely incidental. There was no sensible way to change the runtime semantics with this spelling, so no attempt was made. Although it’s worth pointing out, that even with TypeAlias you can write code that passes type checking, but fails at runtime, due to forward references, e.g:
Foo: TypeAlias = "int"
Foo("8") # crashes at runtime, type checker accepts it
You can consider the new behavior a regression, but I actually consider it a fix, because it was never safe to use type aliases in this way in the first place and type checkers probably should’ve always flagged non-type-context uses of type aliases as errors.
Either way the ship for this discussion has kind of sailed. This has been brought up multiple times in the past both during PEP-695’s implementation and shortly after 3.12 was released and the fact that TypeAliasType doesn’t behave transparently like e.g. a typing._GenericAlias would is entirely intentional.
Changing it at this point wouldn’t even really be all that helpful, since you would not be able to take advantage of it until you drop support for anything below Python 3.14 at the earliest.
The best you can hope for is for tooling to better understand when something should be turned into a type alias and when it should remain a regular assignment.
in fact, not even something as simple as type[str] is, at runtime that’s a typing._GenericAlias, it just behaves transparently in most cases, so you don’t notice it as much ↩︎
This is the part I wish was still addressed, but handled differently.
If variables used as part of type expressions carried both their value and their type, there would be no issue with your counter-example. When used as a type expression, the type of the annotation would be used, when used as a value, the value would be used, and normal rules for assignability would apply.
There’s actually more (ergonomic) issues caused by the type system trying to ignore and prohibit the overlap of these than there is anything fixed by it since other fixes would have been type-safe, and this current distinction also sort of blocks any ideas of using type alias statement machinery to defer other things till use like typing only imports (as there would be a clash with the type system’s understanding to enable type import name)
First, thanks for engaging with me and spending time laying out the arguments!
Well, I see at least three directions where the ship might not have sailed yet:
One can remove the deprecation of “TypeAlias”. This would probably require a renaming of the new “type alias” to something else to distinguish between these.
One can design an explicit replacement of “TypeAlias” that could be promoted instead of having to remove explicit typing if one wants to combine the use of a variable in both type and value expressions. This might amount to either an extension of one of the existing solutions, or a new syntax.
It is in any case never too late to update documentation with clear distinctions between these use cases and remove misdirecting statements to the effect hat the new “type alias” is functionally the same as the old. I think be both agree that, in practice, it isn’t, regardless of the intention.
The fact that new features take time is not really an argument for anything, though. The question is rather if it is worth it to revisit this issue.
“Deprecated” in the case of TypeAlias doesn’t actually mean it’s ever going to get removed, raise a DeprecationWarning or that it shouldn’t be used. It just means that the type statement should be preferred for most use-cases, so you should only use it in cases where the type statement doesn’t accomplish what you want to accomplish.
I do however agree with you, that documentation can and should be improved in order to decrease confusion.
I don’t think there’s a need for a new typing construct. I can relate to the urge of wanting to annotate every single declaration with an explicit type, so there’s no uncertainty in the system, but you also have to balance that against allowing the type checker to make some inferences and trusting[1], that it will get things right, since we don’t actually currently have enough expressiveness to fully describe every possible type using type expressions. So unless it’s for documentation purposes, you sometimes have to stop yourself from adding an annotation, when it’s not required.
I don’t see a stated belief that TypeAlias will be removed in that issue. Just that the feature is mostly obsolete at this point and will become more and more fringe as time goes on, so it was considered a waste of precious engineering resources, which seems like a totally fair assessment.
You can’t expect every runtime typing library to support every single feature. TypeAlias is especially challenging for something like beartype, exactly because it’s completely transparent, i.e. it doesn’t create something that you can track, besides the TypeAlias in __annotations__ in the scope the type alias was originally declared in, if you’re in a context where that attribute gets populated[1]. So I’m not surprised that they support TypeAliasType but not TypeAlias, I would make the same decision[2].
but the problem is you don’t actually know where the type alias was declared just from its value and even if you can reconstruct the name using introspection, to locate the declaration, you’re still a couple of steps far away from being able to determine whether or not that value represents a type alias ↩︎
I might never have put TypeAlias support on the table in the first place, even without TypeAliasType being a thing ↩︎
Perhaps not the best example of this, and the point was in any case not to discuss resource management of a typing library. The tongue-in-cheek deprecation notice which that library now provides for TypeAlias users is, however, a gem that perfectly illustrates my frustration:
Combine the above two approaches via The Ultimate Type Alias (TUTA),
a hidden ninja technique that supports all Python versions and static
type-checkers but may cause coworker heads to pop off like in that one
Kingsman scene:
# Instead of this...
from typing import TypeAlias
alias_name: TypeAlias = alias_value
# ..."just" do this. If you think this sucks, know that you are not alone.
from typing import TYPE_CHECKING, NewType, TypeAlias # <-- sus af
from sys import version_info # <-- code just got real
if TYPE_CHECKING: # <-- if static type-checking, then PEP 613
alias_name: TypeAlias = alias_value # <-- grimdark coding style
elif version_info >= (3, 12): # <-- if Python >= 3.12, then PEP 695
exec("type alias_name = alias_value") # <-- eldritch abomination
else: # <-- if Python < 3.12, then PEP 484
alias_name = NewType("alias_name", alias_value) # <-- coworker gives up here
I can understand your frustration, if that’s the solutions people come up with in good faith, we definitely have a documentation problem, because there’s a much simpler way to support earlier versions of python, while still getting the benefits of TypeAliasType in beartype: typing_extensions.TypeAliasType
That’s obviously still less ergonomic than TypeAlias or the new type statement, but it is a heck of a lot better than that abomination, or using NewType, which changes the semantics, unless you specifically need the type to be constructable using simple call-syntax and don’t care you’re creating a distinct subtype that will need to be used consistently throughout your program.