Important
This PEP is a historical document: seeTypeIs andtyping.TypeIs for up-to-date specs and documentation. Canonical typing specs are maintained at thetyping specs site; runtime typing behaviour is described in the CPython documentation.
×
See thetyping specification update process for how to propose changes to the typing spec.
This PEP proposes a new special form,TypeIs, to allow annotating functions that can be usedto narrow the type of a value, similar to the builtinisinstance(). Unlike the existingtyping.TypeGuard special form,TypeIs can narrow the type in both theifandelse branches of a conditional.
Typed Python code often requires users to narrow the type of a variable based on a conditional.For example, if a function accepts a union of two types, it may use anisinstance() checkto discriminate between the two types. Type checkers commonly support type narrowing based on variousbuiltin function and operations, but occasionally, it is useful to use a user-defined function toperform type narrowing.
To support such use cases,PEP 647 introduced thetyping.TypeGuard special form, whichallows users to define type guards:
fromtypingimportassert_type,TypeGuarddefis_str(x:object)->TypeGuard[str]:returnisinstance(x,str)deff(x:object)->None:ifis_str(x):assert_type(x,str)else:assert_type(x,object)
Unfortunately, the behavior oftyping.TypeGuard has some limitations that make itless useful for many common use cases, as explained also in the “Motivation” section ofPEP 724.In particular:
TypeGuard return type as the narrowed type if thetype guard returnsTrue. They cannot use pre-existing knowledge about the type of thevariable.False, the type checker cannot apply anyadditional narrowing.The standard library functioninspect.isawaitable() may serve as an example. Itreturns whether the argument is an awaitable object, andtypeshedcurrently annotates it as:
defisawaitable(object:object)->TypeGuard[Awaitable[Any]]:...
A userreported an issue to mypy aboutthe behavior of this function. They observed the following behavior:
importinspectfromcollections.abcimportAwaitablefromtypingimportreveal_typeasyncdeff(t:Awaitable[int]|int)->None:ifinspect.isawaitable(t):reveal_type(t)# Awaitable[Any]else:reveal_type(t)# Awaitable[int] | int
This behavior is consistent withPEP 647, but it did not match the user’s expectations.Instead, they would expect the type oft to be narrowed toAwaitable[int] in theifbranch, and toint in theelse branch. This PEP proposes a new construct that doesexactly that.
Other examples of issues that arose out of the current behavior ofTypeGuard include:
numpy.isscalar)dataclasses.is_dataclass())typing.TypeGuard to work likeisinstance())else branch)else branch)else branch)inspect.isawaitable())asyncio.iscoroutinefunction)The problems with the current behavior oftyping.TypeGuard compel us to improvethe type system to allow a different type narrowing behavior.PEP 724 proposed to changethe behavior of the existingtyping.TypeGuard construct, but webelievethat the backwards compatibility implications of that change are too severe. Instead, we proposeadding a new special form with the desired semantics.
We acknowledge that this leads to an unfortunate situation where there are two constructs witha similar purpose and similar semantics. We believe that users are more likely to want the behaviorofTypeIs, the new form proposed in this PEP, and therefore we recommend that documentationemphasizeTypeIs overTypeGuard as a more commonly applicable tool. However, the semantics ofTypeGuard are occasionally useful, and we do not propose to deprecate or remove it. In the longrun, most users should useTypeIs, andTypeGuard should be reserved for rare caseswhere its behavior is specifically desired.
A new special form,TypeIs, is added to thetypingmodule. Its usage, behavior, and runtime implementation are similar tothose oftyping.TypeGuard.
It accepts a singleargument and can be used as the return type of a function. A function annotated as returning aTypeIs is called a type narrowing function. Type narrowing functions must returnboolvalues, and the type checker should verify that all return paths returnbool.
Type narrowing functions must accept at least one positional argument. The typenarrowing behavior is applied to the first positional argument passed tothe function. The function may accept additional arguments, but they arenot affected by type narrowing. If a type narrowing function is implemented asan instance method or class method, the first positional argument mapsto the second parameter (afterself orcls).
To specify the behavior ofTypeIs, we use the following terminology:
TypeIs input typeTypeIs return typeTypeIs returnedTrue)TypeIs returnedFalse)defnarrower(x:I)->TypeIs[R]:...deffunc1(val:A):ifnarrower(val):assert_type(val,NP)else:assert_type(val,NN)
The return typeR must beconsistent withI. The type checker shouldemit an error if this condition is not met.
Formally, typeNP should be narrowed toA∧R,the intersection ofA andR, and typeNN should be narrowed toA∧¬R, the intersection ofA and the complement ofR.In practice, the theoretic types for strict type guards cannot be expressedprecisely in the Python type system. Type checkers should fall back onpractical approximations of these types. As a rule of thumb, a type checkershould use the same type narrowing logic – and get results that are consistentwith – its handling ofisinstance(). This guidance allows for changes andimprovements if the type system is extended in the future.
Type narrowing is applied in both the positive and negative case:
fromtypingimportTypeIs,assert_typedefis_str(x:object)->TypeIs[str]:returnisinstance(x,str)deff(x:str|int)->None:ifis_str(x):assert_type(x,str)else:assert_type(x,int)
The final narrowed type may be narrower thanR, due to the constraints of theargument’s previously-known type:
fromcollections.abcimportAwaitablefromtypingimportAny,TypeIs,assert_typeimportinspectdefisawaitable(x:object)->TypeIs[Awaitable[Any]]:returninspect.isawaitable(x)deff(x:Awaitable[int]|int)->None:ifisawaitable(x):# Type checkers may also infer the more precise type# "Awaitable[int] | (int & Awaitable[Any])"assert_type(x,Awaitable[int])else:assert_type(x,int)
It is an error to narrow to a type that is not consistent with the input type:
fromtypingimportTypeIsdefis_str(x:int)->TypeIs[str]:# Type checker error...
TypeIs is also valid as the return type of a callable, for examplein callback protocols and in theCallable special form. In thesecontexts, it is treated as a subtype of bool. For example,Callable[...,TypeIs[int]]is assignable toCallable[...,bool].
UnlikeTypeGuard,TypeIs is invariant in its argument type:TypeIs[B] is not a subtype ofTypeIs[A],even ifB is a subtype ofA.To see why, consider the following example:
deftakes_narrower(x:int|str,narrower:Callable[[object],TypeIs[int]]):ifnarrower(x):print(x+1)# x is an intelse:print("Hello "+x)# x is a strdefis_bool(x:object)->TypeIs[bool]:returnisinstance(x,bool)takes_narrower(1,is_bool)# Error: is_bool is not a TypeIs[int]
(Note thatbool is a subtype ofint.)This code fails at runtime, because the narrower returnsFalse (1 is not abool)and theelse branch is taken intakes_narrower().If the calltakes_narrower(1,is_bool) was allowed, type checkers would fail todetect this error.
As this PEP only proposes a new special form, there are no implications onbackwards compatibility.
None known.
Introductions to typing should coverTypeIs when discussing how to narrow types,along with discussion of other narrowing constructs such asisinstance(). Thedocumentation should emphasizeTypeIs overtyping.TypeGuard; while thelatter is not being deprecated and its behavior is occasionally useful, we expect that thebehavior ofTypeIs is usually more intuitive, and most users should reach forTypeIs first. The rest of this section contains some example content that couldbe used in introductory user-facing documentation.
TypeIsPython code often uses functions likeisinstance() to distinguish betweendifferent possible types of a value. Type checkers understandisinstance()and various other checks and use them to narrow the type of a variable. However,sometimes you want to reuse a more complicated check in multiple places, oryou use a check that the type checker doesn’t understand. In these cases, youcan define aTypeIs function to perform the check and allow type checkersto use it to narrow the type of a variable.
ATypeIs function takes a single argument and is annotated as returningTypeIs[T], whereT is the type that you want to narrow to. The functionmust returnTrue if the argument is of typeT, andFalse otherwise.The function can then be used inif checks, just like you would useisinstance().For example:
fromtypingimportTypeIs,LiteraltypeDirection=Literal["N","E","S","W"]defis_direction(x:str)->TypeIs[Direction]:returnxin{"N","E","S","W"}defmaybe_direction(x:str)->None:ifis_direction(x):print(f"{x} is a cardinal direction")else:print(f"{x} is not a cardinal direction")
TypeIs functionATypeIs function allows you to override your type checker’s type narrowingbehavior. This is a powerful tool, but it can be dangerous because an incorrectlywrittenTypeIs function can lead to unsound type checking, and type checkerscannot detect such errors.
For a function returningTypeIs[T] to be safe, it must returnTrue if and only ifthe argument is compatible with typeT, andFalse otherwise. If this condition isnot met, the type checker may infer incorrect types.
Below are some examples of correct and incorrectTypeIs functions:
fromtypingimportTypeIs# Correctdefgood_typeis(x:object)->TypeIs[int]:returnisinstance(x,int)# Incorrect: does not return True for all intsdefbad_typeis1(x:object)->TypeIs[int]:returnisinstance(x,int)andx>0# Incorrect: returns True for some non-intsdefbad_typeis2(x:object)->TypeIs[int]:returnisinstance(x,(int,float))
This function demonstrates some errors that can occur when using a poorly writtenTypeIs function. These errors are not detected by type checkers:
defcaller(x:int|str,y:int|float)->None:ifbad_typeis1(x):# narrowed to intprint(x+1)else:# narrowed to str (incorrectly)print("Hello "+x)# runtime error if x is a negative intifbad_typeis2(y):# narrowed to int# Because of the incorrect TypeIs, this branch is taken at runtime if# y is a float.print(y.bit_count())# runtime error: this method exists only on int, not floatelse:# narrowed to float (though never executed at runtime)pass
Here is an example of a correctTypeIs function for a more complicated type:
fromtypingimportTypedDict,TypeIsclassPoint(TypedDict):x:inty:intdefis_point(x:object)->TypeIs[Point]:return(isinstance(x,dict)andall(isinstance(key,str)forkeyinx)and"x"inxand"y"inxandisinstance(x["x"],int)andisinstance(x["y"],int))
TypeIs andTypeGuardTypeIs andtyping.TypeGuard are both tools for narrowing the type of a variablebased on a user-defined function. Both can be used to annotate functions that take anargument and return a boolean depending on whether the input argument is compatible withthe narrowed type. These function can then be used inif checks to narrow the typeof a variable.
TypeIs usually has the most intuitive behavior, but itintroduces more restrictions.TypeGuard is the right tool to use if:
list[object] tolist[int].TypeIs only allows narrowing betweencompatible types.True for all input values that are compatible withthe narrowed type. For example, you could have aTypeGuard[int] that returnsTrueonly for positive integers.TypeIs andTypeGuard differ in the following ways:
TypeIs requires the narrowed type to be a subtype of the input type, whileTypeGuard does not.TypeGuard function returnsTrue, type checkers narrow the type of thevariable to exactly theTypeGuard type. When aTypeIs function returnsTrue,type checkers can infer a more precise type combining the previously known type of thevariable with theTypeIs type. (Technically, this is known as an intersection type.)TypeGuard function returnsFalse, type checkers cannot narrow the type ofthe variable at all. When aTypeIs function returnsFalse, type checkers can narrowthe type of the variable to exclude theTypeIs type.This behavior can be seen in the following example:
fromtypingimportTypeGuard,TypeIs,reveal_type,finalclassBase:...classChild(Base):...@finalclassUnrelated:...defis_base_typeguard(x:object)->TypeGuard[Base]:returnisinstance(x,Base)defis_base_typeis(x:object)->TypeIs[Base]:returnisinstance(x,Base)defuse_typeguard(x:Child|Unrelated)->None:ifis_base_typeguard(x):reveal_type(x)# Baseelse:reveal_type(x)# Child | Unrelateddefuse_typeis(x:Child|Unrelated)->None:ifis_base_typeis(x):reveal_type(x)# Childelse:reveal_type(x)# Unrelated
TheTypeIs special formhas been implementedin thetyping_extensions module and will be released in typing_extensions 4.10.0.
Implementations are available for several type checkers:
TypeGuardPEP 724 previously proposed changing the specified behavior oftyping.TypeGuard sothat if the return type of the guard is consistent with the input type, the behavior proposedhere forTypeIs would apply. This proposal has some important advantages: because itdoes not require any runtime changes, it requires changes only in type checkers, making it easierfor users to take advantage of the new, usually more intuitive behavior.
However, this approach has some major problems. Users who have writtenTypeGuard functionsexpecting the existing semantics specified inPEP 647 would see subtle and potentially breakingchanges in how type checkers interpret their code. The split behavior ofTypeGuard, where itworks one way if the return type is consistent with the input type and another way if it is not,could be confusing for users. The Typing Council was unable to come to an agreement in favor ofPEP 724; as a result, we are proposing this alternative PEP.
Both this PEP and the alternative proposed inPEP 724 have shortcomings. The latter arediscussed above. As for this PEP, it introduces two special forms with very similar semantics,and it potentially creates a long migration path for users currently usingTypeGuardwho would be better off with different narrowing semantics.
One way forward, then, is to do nothing and live with the current limitations of the type system.However, we believe that the limitations of the currentTypeGuard, as outlined in the “Motivation”section, are significant enough that it is worthwhile to change the type system to address them.If we do not make any change, users will continue to encounter the same unintuitive behaviors fromTypeGuard, and the type system will be unable to properly represent common type narrowing functionslikeinspect.isawaitable.
This PEP currently proposes the nameTypeIs, emphasizing that the special formTypeIs[T]returns whether the argument is of typeT, and mirroringTypeScript’s syntax.Other names were considered, including in an earlier version of this PEP.
Options include:
IsInstance (post by Paul Moore):emphasizes that the new construct behaves similarly to the builtinisinstance().Narrowed orNarrowedTo: shorter thanTypeNarrower but keeps the connection to “type narrowing”(suggested by Eric Traut).Predicate orTypePredicate: mirrors TypeScript’s name for the feature, “type predicates”.StrictTypeGuard (earlier drafts ofPEP 724): emphasizes that the new construct performs a stricterversion of type narrowing thantyping.TypeGuard.TypeCheck (post by Nicolas Tessore):emphasizes the binary nature of the check.TypeNarrower: emphasizes that the function narrows its argument type. Used in an earlier version of this PEP.Much of the motivation and specification for this PEP derives fromPEP 724. Whilethis PEP proposes a different solution for the problem at hand, the authors ofPEP 724, Eric Traut, RichChiodo, and Erik De Bonte, made a strong case for their proposal and this proposalwould not have been possible without their work.
This document is placed in the public domain or under theCC0-1.0-Universal license, whichever is more permissive.
Source:https://github.com/python/peps/blob/main/peps/pep-0742.rst
Last modified:2024-10-17 12:49:39 GMT