Type aliases without implicit conversion ("NewType") #1284
Description
Activity
@gvanrossum, @ddfisher, and I discussed this for a while in person. The use case we understand relatively well is for things like user IDs, where one rarely wants to manipulate them as the underlying type (like doing arithmetic.) For things like HTML fragments, we'll want to understand the use case better.
Even for user IDs, one thing to be careful of getting right is equality -- you need to be able to write
if user_id == requester_user_id:or whatever, so you need at least that one operation. And you probably want an error if someone ever compares a user ID to a plain int.It'll also be important to make a newtype like this possible to gradually adopt in an already-annotated codebase -- so that if you have a bunch of code already annotated with
intfor the user IDs, you can go back and annotate a swath of it at a time withUserIdinstead. David suggested having a feature likeUncheckedUnion, which is sort of likeAnybut narrowed to the specific types in the union; then one could write e.g.UncheckedUserId = UncheckedUnion[UserId, int], and at the boundary betweenUserIdcode and still-intcode type things asUncheckedUserId. That saves having to write a bunch of casts, and it means no runtime effect.I was playing with this the other day and had the idea to use classes that the type checker would see, but which would never be instantiated at runtime. It kind-of works :-)
At first I tried:
class Celsius(int): def __new__(cls, value): return value class Fahrenheit(int): def __new__(cls, value): return value def is_it_boiling(temp: Celsius) -> bool: return temp > 100 c = Celsius(80) f = Fahrenheit(120) print('is', c, 'boiling?', 'yes' if is_it_boiling(c) else 'no') print('is', f, 'boiling?', 'yes' if is_it_boiling(f) else 'no')
The classes are only there to introduce types for the type checker — at runtime there is only
ints. The__new__methods lets you use the class when creating a new value. The type checker apparently expects that__new__returns theclspassed in, soc = Celsius(123)will conveniently make the type checker assign the typeCelsiustoc. Alternatively, you could have usedcast(Celsius, value).This correctly flags the second call to
is_it_boilingwhere the argument was in Fahrenheit, not Celsius.However, since both classes inherit from
int, they can be used in mixed expressions likex = Celsius(10) + Fahrenheit(20)
That does not give an error since the type checker sees that as a call to
int.__add__(other: int)andFahrenheitis a subclass ofint.To get around that, I tried removing the common base class. That looks like this:
from typing import Union class Celsius: def __new__(cls, value): return value def _binop(self, other: Union['Celsius', int]) -> 'Celsius': pass __add__ = __sub__ = _binop def _boolbinop(self, other: Union['Celsius', int]) -> bool: pass __lt__ = __le__ = __eq__ = __ne__ = __ge__ = __gt__ = _boolbinop
The
Fahrenheitclass would need similar boilerplate, which one might be able to put in a generic base class. With these definitions, a line likex = Celsius(10) + Fahrenheit(20)
is flagged with
error: Argument 1 has incompatible type "Fahrenheit"; expected "Union[Celsius, Number]"The use of
Union[..., int]allows you to do comparisons with normal integers and you can thus save on the number ofCelsius(...)wrappers needed. (I had originally thought I could usenumbers.Numberinstead ofint, but that doesn't work for some reason.)
So, the basic idea was: make a new type unrelated to all other types for each newtype. The new type gets the special methods needed to behave like the base type it is an alias for. At runtime, the new type is never instantiated — so there's no performance penalty due to an extra layer of indirection.
Since the types are not actually used at runtime,
isinstancechecks will fail (isinstance(Celsius(123), Celsius)isFalsesincecis anintat runtime).Hello!
Excuse me for not bringing much except a long
+1.We very much would like to start using mypy, but since most of our codebase is just very nasty python3 code (none of them have stubs so far, okay maybe requests), so the important parts would be the "business logic", and those parts would mostly benefit from these custom value types. Though using classes works, and they only have a negligible memory overhead, they are a bit distracting (especially since developers have to be conscious of them to not use them for anything else than annotating/hinting).
Thanks for your efforts!
This has been a fairly frequently requested feature. The main challenge is that there are multiple potential, different use cases, and we don't understand how important all of these are. Supporting all of the possible use cases might be hard, and almost certainly not a good idea anyway.
Here's a list of things which might be useful to support (some of these could be worthless):
- Create a very limited type for things like IDs that is separate from other types but is represented at runtime as an
int/stror similar. It wouldn't inherit most operations from the target type. Currently defining a dummy class as proposed above can be used as a workaround, and we could easily provide a better syntax for this. Type checking of==operations is still an open issue, as mypy allow arbitrary objects compared for equality. - Create something that provides a (tweaked) subset of operations supported by
intor another type, but is represented at runtime asint(or whatever). TheCelsiusexample above is like this. - Create something that behaves more or less like a subclass of
int/strbut is a regularint/strat runtime. This already kind of works via a__new__hack, but it's not quite perfect as binary operations will likely have wrong result types by default (e.g.MyInt() + MyInt()isintinstead ofMyInt). - Create something that behaves exactly like another type such as
strbut that is not compatible at all with the another type. For example, create an aliasPathforstr, and don't support mixingPathandstrobjects in operations at all. Also,Pathwould not be a subtype ofstr. However,Path() + Path()would be okay.
Use cases 1, 2 and 4 could be implemented via wrapper objects, but this would have extra memory overhead, and potentially also speed impact. Also, use case 4 would potentially require replicating the entire interface of the target class, resulting in a lot of boilerplate code. However, use case 4 seems pretty marginal. Currently use case 3 isn't properly supported, as far as I can see.
My guess is that use case 1 is reasonably common, but the others are less so.
- Create a very limited type for things like IDs that is separate from other types but is represented at runtime as an
For start simply the ability to define a type, while avoiding the use of
class, without any primitive type semantics would be okay.Usually the fact that a
user_idis an integral type (or that it's numeric at all) doesn't matter. It's just a unique id for lookups and update access. And it'd help catch a lot of bugs due to accidentally calling a function with the wrong arguments, returning the user name instead of the id and so on. (Or maybe a better example is user name vs. email address, both are usually represented as simple (base)string typed variables/values, yet semantically they are completely different.)Could you work out a complete example of how that would look?
(I'm not Pas, but here's my two cents for an example:)
user.py
This is a model. Maybe some business logic.from typing import TypeAlias UserId = TypeAlias('UserId', int) class User(object): @classmethod def get_by_user_id(cls, user_id): # type: (UserId) -> User db_result = db.hey_this_function_accepts_an_int(user_id) return User(db_result) @property def name(self): ...
user_controller.py
some HTTP controller, whose actions can only take in primitive arguments. Its only purpose in life is to call into smarter logic like the aboveuser.py, and maybe some validation.from user import UserId, User # just use a type: comment to say 'hey, I want to treat user_id_as_int as a UserId ' def get_user_name(user_id_as_int): # type: (int) -> str user_id = user_id_as_int # type: UserId # the above would fail if 'user_id_as_int' were not an int. # could probably also be accomplished by a cast(). return User.get_by_user_id(user_id).name
The basic rules I could foresee would be:
RULE 1: functions that accept an int, can also accept a UserId- as exemplified by
db.hey_this_function_accepts_an_intinget_by_user_id- the passed in variable is a UserId. - Highly simplifies migrations to a TypeAlias-friendly setup.
RULE 2: functions that accept a UserId need to have their parameters explicitly identified as a UserId, through either a #type: or a cast().
- as exemplified in the controller.
- forces the person consuming the library to say, "oh geez, okay, i promise you that this is a UserId".
[sidenote for if this proposal does go through: this will probably be used for IDs a lot. You should publish some opinion like the mypy authors think capitalizating like 'UserId' is more idiomatic casing than 'UserID', or the other way around. The most annoying thing in the world would be if somebody had a TypeAlias called UserID and another guy had a type alias called EmailId.]
- as exemplified by
Thanks to wittekm's detailed example all I have to add is an other use case where type equivalence can cause problems.
So let's say we have this:
ConflictResolutionFunction = Callable[[Any, str, Any, T], Tuple[Optional[T], Optional[T]]]Then hinting these callbacks (and other dynamically loaded and utilized Callables) seems like a big selling point of mypy, but in this case I wasn't able to come up with a class that somehow subtypes this type. (Probably because I don't know enough about how special the code in typing.py is, or maybe just because Callable is Final.) And of course it's unlikely that there are many similarly strange/ugly typed variables (functions), though there are a lot of uses of callable values with simpler types.
For example Flask and Werkzeug use class valued variables (response_class, app_ctx_globals_class and others), and they could be typed as
Callable[[], Any]s, because due to duck typing the actual classes instantiated by the framework doesn't have to implement anything. (Or even if Flask uses ABCs, some other code might not.)(I'll respond to @wittekm's example and rules in a separate comment.)
@PAStheLoD: Callable is indeed final, you can't subclass it. It's about as special as Union or Tuple. And type-checking of Callable types is entirely structural, since that's what "callable" means fundamentally. (In fact it's a lot more fundamental than "int" or "str".)
I think what you're trying to do here is the following.
You have an API that takes a callback that implements some policy (e.g. for caching). There may be some predefined policies you are also fine with users implementing their own policies. However, you want them to explicitly state when they are writing a policy, so that only functions explicitly marked as policy functions are accepted as policy parameters in your APIs.
In a dynamic world, you could easily do this by requiring a
@policydecorator that just adds a special_policy_attribute to the function object, and in the API you check the policy callbacks for that attribute. But you wish that you could let mypy do this for you -- users would still add a@policydecorator, but that decorator would subtly modify the type, and your policy API arguments would be declared as only accepting such a policy. Then mypy would catch mistakes without runtime overhead.I think this is an interesting if slightly esoteric use case, and it looks possible that the solution onto which we are converging for simpler type aliases will made to work for callables too. For example:
# Library code T = TypeVar('T') _Policy = Callable[[Any, str, Any, T], Tuple[Optional[T], Optional[T]]] # The raw signature Policy = TypeAlias('Policy', _Policy) # The marked type def make_policy(func: _Policy) -> Policy: # The decorator return cast(func, Policy) # Maybe the cast isn't even needed def some_api(p: Policy) -> None: <this only accepts functions marked with @Policy> # User code from library import T, Policy, make_policy, some_api @make_policy def my_policy(a, b: str, c, d: T) -> Tuple[Optional[T], Optional[T]]: <implement a policy> def not_a_policy(a, b: str, c, d: T) -> Tuple[Optional[T], Optional[T]]: <looks like a policy but isn't> some_api(my_policy) # OK some_api(not_a_policy) # Error (from mypy!)
@wittekm: I have a few questions about the details of your proposal.
(I also edited your example a bit to correct obvious typos, like a missing
selffor thenameproperty and to make theget()call correspond to the definition ofget_by_user_id(). I hope that's OK.)You are proposing asymmetric rules: any function that takes an int will silently accept a UserId, while any function that takes a UserId won't take an int. (In mypy -- at runtime they both accept either type since UserId is just int at runtime.)
A slight problem with this is that it seems to allow int operations on UserId instances, since operations are just syntactic sugar for functions. So this code would still be allowed:
def get(uid: UserId) -> None: x = uid + 1 # Has type int, but makes no sense <do something with x>
I'm not sure if this is a show-stopping deficiency or something that we can live with. It seems to go against Jukka's (1) from #1284 (comment), where he says that the alias wouldn't inherit most operations. I'm curious if we can define a rule that actually follows (1). Certainly some operations should still be allowed, e.g.
__str__or__eq__. How would we define the set of operations thatTypeAlias()strips without special-casing e.g. int? This is essentially the problem from #1284 (comment) (the example with Celsius and Fahrenheit). We really need to decide about this before we can move forward.My other question is about using
cast()vs# typecomments. Your example hasuser_id = user_id_as_int # type: UserId
and you wrote (both in the comment and in RULE 2) that it might also possibly use a cast. I would really hope we won't need to use or encourage casts here, since it's an expensive runtime operation (at least it is until we teach CPython about it -- right now it is a user-defined function, which is incredibly slow compared to a plain assignment). Unfortunately (and here @JukkaL might know better) I think that there's not much difference between the above and
Use.get_by_user_id(user_id_as_int)
since in both cases mypy sees an int (
user_id_as_int) in a context where a UserId is required. To understand this it helps to realize that putting a# typecomment on an assignment is really a crutch for a variable declaration -- in some hypothetical future Python syntax the above example might be written asvar user_id: UserId = user_id_as_int
IOW the
# type commentjust gives the type of the variable, not of the expression, and mypy then has to decide whether the expression's type is acceptable for the variable's type. There really can be only a single rule to decide whether an expression's type is acceptable for a given context (either a variable or argument supplies a context).However, all that seems to point us in the direction of requiring a cast, and as I said I really don't like that. How can we formulate a rule that does what we want? You tried with RULE 1 and RULE 2 but I think we'll need some additional subtlety that eludes me. Maybe we need to distinguish between different kinds of contexts (e.g. assignment being different from arguments) and allow conversion of int to UserId in one context but not another.
Going back to @JukkaL's list of use cases (1)-(4) and the Celsius/Fahrenheit example, I would also like to discuss runtime costs more. Some use cases require defining a new class using standard Python class syntax (e.g.
class Celsius(int): ...). There's no way that can have zero runtime cost, and I think if we're going that road it's not worth also adding special handling to mypy. The thing that's exciting would be to have aTypeAlias()special form that disappears entirely at runtime. But then I want to avoid the need for casts too. Here I am getting a headache. :-)Finally. Once we agree on how to do it I think we should come up with a better name, since "type alias" in PEP 484 is already used for pure aliases that aren't type-checked -- they are just shorthands to avoid having to write the same thing over and over.
Sorry for the rambling!
PS. Re: capitalization of UserID vs. UserId, I find the latter looks better. PEP 8 has something to say about abbreviations like HTTP (it prefers HTTPServerError), but I think that's really more about initialisms like HTTP (== HyperText Transfer Protocol), while to me ID feels more like a shortening of "Identity". I could see HTTP being evolved from H.T.T.P., but writing I.D. for Identity makes no sense -- even though I've seen it.
@gvanrossum: Yes, thank you, that's the broadest use case I had in mind, sans the decorator, as I was envisioning that marking a function as this "non-equal alias" type would be enough information (both for the developer and mypy).
- [...] I was envisioning that marking a function as this "non-equal alias" type would be enough information (both for the developer and mypy).So how were you thinking of marking a function other than using a decorator?
@gvanrossum: You are correct, mypy uses the same rules for assignment and functions calls. Making assignments special wouldn't be too hard technically, but this special casing feels pretty ad-hoc to me. Users might expect that an assignment with a type annotation can be used as a cast in general and would get confused as it would only work for certain types. Using a type alias would still have a runtime cost (an extra assignment statement), and I'm not yet convinced that the runtime overhead of a call is significant enough to make the type system less consistent.
Instead of using
castor an assignment, there are other options we could pursue. Here are a few ideas.A) Make the alias a callable object
This should be slightly more efficient than a cast as there is only a single argument. This is arguably more readable and less error-prone that with a cast:
UserId = NewType('UserId', int) def get_user_name(user_id): # type: (int) -> str return User.get_by_user_id(UserId(user_id)).nameB) Special type that allows both
UserIdandintWe'd add a new kind of type, but we wouldn't need to touch the semantics of assignments. The idea is that a special type would accept
intas a value, but the type would still be a subtype ofUserId. This is similar to the unchecked union proposed by @ddfisher, but I'd rather use a different name for this. I don't have a great proposal for the name, though. The code would be efficient as we don't need an assignment or a call.Example:
from typing import NewType, Implicit UserId = NewType('UserId', int) def get_user_name(user_id): # type: (Implicit[UserId]) -> str return User.get_by_user_id(user_id).name # ok get_user_name(2) # okThe type
Implicit[UserID]would allowUserIdorint, but maybe it wouldn't allow other aliased types such asFileId(even if that is internally anint). It would be compatible withUserId-- it's basically a bridge betweenintandUserId.Implicit[t]probably isn't the best way to phrase this. Some other ideas, none of which seem very intuitive:UserId.implicitUnchecked[UserId]Promote[UserId]
10 remaining items
I don't even know if this PEP change will go through, or when (even though I have it in the 3.5.2 milestone for the PEP -- that just means I want to think about it).
- changed the title
[-]Type aliases without implicit conversion ("newtype")[/-][+]Type aliases without implicit conversion ("Newtype")[/+]on Jun 6, 2016 So the PEP work on this feature (spec in pep-0484.txt and runtime support in typing.py) is complete (python/typing#226) -- all we need is the mypy implementation now!
Awesome!
- changed the title
[-]Type aliases without implicit conversion ("Newtype")[/-][+]Type aliases without implicit conversion ("NewType")[/+]on Jun 6, 2016 Since
typing.py's implementation ofNewTypereturns new function every time, areT1andT2going to be different types?T1 = NewType('T', int) T2 = NewType('T', int)
The example would be rejected since the name passed to
NewTypedoesn't match the assignment target. However, this would define two different types:T1 = NewType('T1', int) T2 = NewType('T2', int)- added and removed
on Jul 20, 2016 - modified the milestones: This milestone has been deleted, This milestone has been deleted
on Jul 20, 2016
Several users (@wittekm among them) have asked for a feature where you can define something that's like a type alias in referring to an existing underlying type at runtime, but which isn't considered equivalent to it in the type-checker -- like what Hack and Haskell call
newtype.One classic application of this would be aliases/newtypes of
str(orunicodeorbytes) to distinguish fragments of HTML, Javascript, SQL, etc., from arbitrary text and enforce that conversions are only done with proper escaping, to prevent classes of vulnerabilities like XSS and SQL injection. A definition might look likeHtmlType = NewType("HtmlType", str).Other classic uses include distinguishing identifiers of different things (users, machines, etc.) that are all just integers, so they don't get mixed up by accident.
A user can always just define a class, say an empty subclass of the underlying type, but if an application is handling a lot of IDs or text fragments or the like, it costs a lot at runtime for them to be some other class rather than actual
strorint, so that that isn't a good solution.The main open question I see in how this feature might work is how to provide for converting these values to the underlying type. For a feature like this to be useful there has to be some way to do that -- so it's possible to write the conversion functions that take appropriate care like escaping text into HTML -- but preferably one that's private to a limited stretch of code, or failing that is at least easy to audit with
grep. In Hack the types are implicitly equivalent just within the source file where the newtype is defined; in Haskell the newtype comes with a data constructor which is the only way to convert, and typically one just doesn't export that from the module where it's defined.The Hack solution could work, and has the advantage that it means no run-time overhead at all, other than invoking the intended conversion functions that live in the newtype's file. It feels odd to me, though, because Python generally doesn't treat specially whether a thing is defined in a given module vs. imported into it. Another solution could be something like
html = HtmlType.make(text)andtext = HtmlType.unmake(html), which would follow perfectly normal Python scoping and would be reasonably auditable.