Adding new syntax is very costly because it requires changes to the Python interpreter and/or many classes of tools (linters, type checkers, stub generators, etc.). It also requires users to understand a new syntactic form, so it comes with a cognitive burden. Any such proposed change should have a very compelling motivation. I recommend starting with a clear and compelling problem statement before proposing a syntax change.
PEP 695 proposed a new type statement because type aliases can be generic. Since PEP 695 was proposing a new way to declare type parameters, it needed to address type aliases. Also, generic type aliases are frequently used incorrectly today. Users tend to omit the type arguments when using a generic because they don’t realize that type arguments are needed. The new type statement in PEP 695 helps to eliminate that confusion by making it more obvious that a generic type alias has one or more type parameters associated with it.
What problem would a new syntax for NewType solve? The use of NewType is relatively rare compared to the use of type aliases. NewType doesn’t support generics, and in my experience, users of NewType are not confused about how it is intended to be used. For those reasons, I don’t see a compelling need to provide a new syntax here. It would save a few keystrokes, but since NewType is not used very often and is a more obscure and advanced feature of the type system, I don’t think that comes close to meeting the bar required to justify a syntax change — or new special casing in type checkers.
You might be able to make a more compelling case if you could gather data that shows NewType is used frequently in current code bases, but I would be surprised if the data backed that up.
A counterpoint would be that introducing brand new type OrderId = int syntax is much more involved than introducing type OrderId = NewType[int] syntax on top of the former one. (I’m not fond of the type OrderId(int) idea, so I’m not arguing about it here.)
I use NewType frequently enough for me the new syntax to be beneficial. Repeating type name is so awkward…
I agree with @erictraut that the bar for introducing a syntax change is high and that NewType probably isn’t commonly enough used to merit special syntactic support.
However if NewTypewere to merit syntactic support in the future, I might advocate that:
from typing import NewType
OrderId = NewType("OrderId", int)
be phrasable using both the new and type keywords as:
new type OrderId = int # a NewType
which would align nicely with the similar type alias:
type OrderId = int # an alias
The use of pseudo-expressions like NewType[int] or NewType(int) presented earlier in this thread seem confusing to me because they look like expressions on their own but are actually part of a larger syntactic form that just spans an entire line.
For me no new syntax is required, if the type can be sub-classed then should be interpreted as a NewType if it cannot then should be interpreted as an Alias:
type Url = str # interpret this as NewType
type UrlList = list[Url] # interpret this as an alias (TypeAliasType)
class Url(str): pass # This is valid
class UrlList(list[Url]): pass # This is not valid
I think in this way we don’t need any new syntax for supporting NewType without importing, but it’s true that it would change current behavior as it may raise warnings in static checkers.
I can’t imagine any instance where I would want to make a real alias over a non generic class, not even when the name of the class is very long (as it can be just renamed, or imported with other name).
Hi, you can have a look at this library that I have created - python-newtype.
It creates new types using a function def NewType(..) and preserves the type information of the returned value even when invoking a method of the supertype.
Example:
import pytest
import re
from newtype import NewType, newtype_exclude
class EmailStr(NewType(str)):
# you can define `__slots__` to save space
__slots__ = (
'_local_part',
'_domain_part',
)
def __init__(self, value: str):
super().__init__()
if "@" not in value:
raise TypeError("`EmailStr` requires a '@' symbol within")
self._local_part, self._domain_part = value.split("@")
@newtype_exclude
def __str__(self):
return f"<Email - Local Part: {self.local_part}; Domain Part: {self.domain_part}>"
@property
def local_part(self):
"""Return the local part of the email address."""
return self._local_part
@property
def domain_part(self):
"""Return the domain part of the email address."""
return self._domain_part
@property
def full_email(self):
"""Return the full email address."""
return str(self)
@classmethod
def from_string(cls, email: str):
"""Create an EmailStr instance from a string."""
return cls(email)
@staticmethod
def is_valid_email(email: str) -> bool:
"""Check if the provided string is a valid email format."""
email_regex = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
return re.match(email_regex, email) is not None
def test_emailstr_replace():
"""`EmailStr` uses `str.replace(..)` as its own method, returning an instance of `EmailStr`
if the resultant `str` instance is a value `EmailStr`.
"""
peter_email = EmailStr("peter@gmail.com")
smith_email = EmailStr("smith@gmail.com")
with pytest.raises(Exception):
# this raises because `peter_email` is no longer an instance of `EmailStr`
peter_email = peter_email.replace("peter@gmail.com", "petergmail.com")
# this works because the entire email can be 'replaced'
james_email = smith_email.replace("smith@gmail.com", "james@gmail.com")
# comparison with `str` is built-in
assert james_email == "james@gmail.com"
# `james_email` is still an `EmailStr`
assert isinstance(james_email, EmailStr)
# this works because the local part can be 'replaced'
jane_email = james_email.replace("james", "jane")
# `jane_email` is still an `EmailStr`
assert isinstance(jane_email, EmailStr)
assert jane_email == "jane@gmail.com"
def test_emailstr_properties_methods():
"""Test the property, class method, and static method of EmailStr."""
# Test property
email = EmailStr("test@example.com")
# `property` is not coerced to `EmailStr`
assert email.full_email == "<Email - Local Part: test; Domain Part: example.com>"
assert isinstance(email.full_email, str)
# `property` is not coerced to `EmailStr`
assert not isinstance(email.full_email, EmailStr)
assert email.local_part == "test"
assert email.domain_part == "example.com"
# Test class method
email_from_string = EmailStr.from_string("classmethod@example.com")
# `property` is not coerced to `EmailStr`
assert (
email_from_string.full_email
== "<Email - Local Part: classmethod; Domain Part: example.com>"
)
assert email_from_string.local_part == "classmethod"
assert email_from_string.domain_part == "example.com"
# Test static method
assert EmailStr.is_valid_email("valid.email@example.com") is True
assert EmailStr.is_valid_email("invalid-email.com") is False
def test_email_str__slots__():
email = EmailStr("test@example.com")
with pytest.raises(AttributeError):
email.hi = "bye"
assert email.hi == "bye"