The fourth chapter of ‘Robust Python’ continues on from where we left off last time. We had previously learned about the benefits of type annotations in general terms, as well as started to understand how we might apply these annotations to simple code examples. But what if things are a bit more complicated? Then we have a few more options at our disposal.
Note that you can assign all of these type assignments to variables (‘type aliases’), which might just make your code that much more readable.
Optional
to catch None
references
Optional
as a type annotation is where you want to allow a specific type or None
to be passed in to a particular function:
from typing import Optional
def some_function(value: Optional[int]) -> int:
# your code goes here
Note that you’ll probably want (and mypy
will remind you if you forget) to handle what happens in both those cases inside your function. (You may need to specifically pass in the —strict-optional
flag to catch this when using mypy
.)
Union
to group types together
This is used when multiple different types can be used for the same variable:
from typing import Union
def returns_the_input(input: Union[str, int]) -> Union[str, int]:
return input
This function doesn’t really do anything, but you get the idea. Note, too, that Optional[int]
is really a version of Union[int, None]
. (The book gets into exactly why we might care about reducing the number of possible options by way of a little detour into set theory.)
Literal
to include only specific values
A little like what I believe enumerations do, we also have the Literal
type. It restricts you to whatever specific values are defined:
from typing import Literal
def some_function(input: Literal[1, 2, 3]) -> int:
return input
Here the function is restricted to inputs that are either 1, 2 or 3. Note that these are a feature that applies to Python 3.8 and above.
Annotated
for more complicated restrictions
These are available, but not really useful since they only function as a communication method. You can specify specific restrictions such as the following (example is taken from the book, p. 56:
from typing import Annotated
int, ValueRange(3,5)]
x: Annotated[str, MatchesRegex('[abc]{2}') y: Annotated[
Read more about it here. The book doesn’t spend much time on it and it seems like it’s probably best left alone for the moment.
NewType
to cover different contexts applied to the same type
NewType
, on the other hand, is quite useful. You can create new types which are identical to some other type, and those new values made with the new type will have access to all the methods and properties as the original type.
from typing import NewType
class Book:
# you implement the class here
= NewType("NewBook", Book)
NewBook
def process_new_book(book: NewBook):
# here you handle what happens to the new book
You can achieve something like the same thing with classes and inheritance, I believe, but this is a lightweight version which might be useful to achieve the same end goal.
Final
to prevent reassignment / rebinding
You can specify that a particular variable should have a single value and that value only. (Note that mutations of an object etc are all still possible, but reassignment to a new memory address is not possible.
from typing import Final
= "Alex" NAME: Final
If you tried to subsequently change this to a different name, mypy
would catch that you’d tried to do this. This can be valuable across very large codebases, where the potential for someone to reassign a variable might be not insignificant.
So there you have it: a bunch of different ways to handle combinations of types and/or more complicated annotation scenarios. The next chapter will cover what happens when we throw collections into the mix, and what type annotation challenges are raised.