Python type annotations are an excellent way to communicate your intentions across your development teams (to enforce your intention, you need to use a type checker like mypy though).
This makes your code base more robust.
Multiple types are accessible in Python. Here we go into the detail of Literal
, NewType
and Final
.
To illustrate the concepts, we will work with the following Cheese object:
from dataclasses import dataclass
@dataclass
class Cheese:
"""
Class representing a cheese object.
"""
name: str
price_per_kilo: float
aoc: bool
if __name__ == "__main__":
cheese = Cheese(
name="roquefort",
price_per_kilo=25.75,
aoc=True
)
Note: AOC
stands for Appellation d’Origine Controlée. As a trademark, this label intends to protect French Cheeses from counterfeiting. Some cheese are protected. Some are not. There are over 1,600 varieties of cheese. Each of them pairing well with specific wines.
Literal
The Literal type allows you to restrict the variable to a very specific set of values.
from dataclasses import dataclass
from typing import Literal
@dataclass
class Cheese:
name: Literal["roquefort", "comté", "brie"]
price_per_kilo: float
aoc: bool
if __name__ == "__main__":
cheese = Cheese(
name="abondance",
price_per_kilo=28,
aoc=True
)
If you run mypy on the command line against that file, you will get an error:
error: Argument "name" to "Cheese" has incompatible type "Literal['abondance']";
expected "Literal['roquefort', 'comté', 'brie']" [arg-type]
Tip: In most development environments you can get the typechecker analysis in real time. In Visual Studio Code, you can get notified of errors as you type using the MyPy Type Checking extension: code --install-extension matangover.mypy
.
Notes:
-
To restrict possible values of a variable, you can also use Python Enumerations. However, Literal is more lightweight.
-
You can also use
Annotated
types to specify more complex constraints (e.g. to constrain a string to a specific size or to match a regular expression) but this type is best served as a communication method.
NewType
A NewType takes an existing type and creates a brand new type that possesses the exact same fields and methods as the existing type.
NewType is useful in a handful of real-world scenario.
For instance, to make sure you only operate upon sanitized strings to prevent SQL injections you could establish a distinction between a str
and a SanitizedString
.
Same to separate between a User
and a LoggedInUser
.
Let’s say we are a gastronomic restaurant. We only want to serve Protected Cheese to our customers.
For the service, a dedicated method called dispense_protected_cheese_to_customer
makes sure that
only protected cheese can be served. And for sure, as it only takes our new ProtectedCheese
type (based on the Cheese
type) as argument and refuses everything else.
from dataclasses import dataclass
from typing import NewType
@dataclass
class Cheese:
name: str
price_per_kilo: float
aoc: bool
ProtectedCheese = NewType("ProtectedCheese", Cheese)
def prepare_for_serving(
cheese: Cheese
) -> ProtectedCheese:
protected_cheese = ProtectedCheese(cheese)
# you can image other suitable methods being
# applied here...
return protected_cheese
def dispense_protected_cheese_to_customer(
protected_cheese: ProtectedCheese
) -> None:
# ...
return None
if __name__ == "__main__":
cheese = Cheese(
name="charolais",
price_per_kilo=47.60,
aoc=True
)
protected_cheese = prepare_for_serving(cheese)
dispense_protected_cheese_to_customer(protected_cheese)
You can try twisting around the above snippet yourself, by trying to serve a non-protected cheese to a customer:
protected_cheese = prepare_for_serving(cheese)
dispense_protected_cheese_to_customer(cheese)
Mypy will complain:
error: Argument 1 to "dispense_protected_cheese_to_customer" has incompatible type "Cheese";
expected "ProtectedCheese" [arg-type]
Note: The prepare_for_serving
method acts as a blessed function, creating our protected cheese from our original cheese blueprint type. It is important to note that the only way to create new types is through a set of blessed functions.
One more thing; NewType
is a useful pattern to be aware of. However, classes and invariants provide a similar but much stronger guarantees to avoid illegal states.
Final Types
Final
types allow you to prevent a type from changing its value over time (e.g. if you do not want the name of a variable to be changed by accident).
from typing import Final
RESTAURANT_NAME: Final = "Le Central"
Should you try set the variable to a new value, mypy will complain:
from typing import Final
RESTAURANT_NAME: Final = "Le Central"
if __name__ == "__main__":
RESTAURANT_NAME = "Le Bois sans feuilles"
error: Cannot assign to final name "RESTAURANT_NAME" [misc]
Final
is hence useful to prevent a variable from being rebound.
Note: Both restaurants belong to the Troisgros family. Both are based in Roanne. Le Bois sans feuilles is a prestigious three-stars restaurant. A documentary about it was released in 2023: Menus Plaisirs – Les Troisgros.
Type Aliases and Variables Annotations
Even though it is cumbersome, you can add type annotations not only to methods but to variables:
def reverse_string(string_candidate: str) -> str:
reversed_string: str = string_candidate[::-1]
return reversed_string
You can also alias a particularly long type:
from typing import Dict, List
DictOfLists = Dict[str, List[int]]
def element_in_lists(
dictionary: DictOfLists,
element: int
) -> list[bool]:
return [element in l for _, l in dictionary.items()]
if __name__ == "__main__":
my_dict = {
"a": [1,2],
"b": [],
"c": [42, 2, -1]
}
result = element_in_lists(
dictionary=my_dict,
element=42
)
print(result)
Acknowledgment
This post is based on the excellent book: Robust Python: Write Clean and Maintainable Code, Patrick Viafore, O’REILLY.