Python Evolutions from 3.9 to 3.13: Typing

Uncategorized

I

10–15 minutes

While the Python standard library can be considered stable, the same cannot be said for the typing component, which is currently in a state of rapid development.

As a reminder, typing in Python is optional, which is perfectly fine for small projects and scripts. However, as soon as a project grows in scale, typing function parameters helps maintain rigor and forces one to consider the correct data flows within the program.

Version 3.9

In Python version 3.9, there is only one evolution in typing, but it significantly improves readability and simplifies typing-related code: built-in types (list, dict…) can now be used to declare typing. 🎉

class Foo:
    def add_items(self, items: list[str]) -> None:
			    ...

I do not know about you, but for me, this reduces the entry barrier for typing so much that I would not mind integrating it even into small scripts.

Version 3.10

Since Python version 3.10, the | operator can now be used to declare type unions (where a function accepts several different types, or to make them optional):

class Foo:
    def set_color(self, color: str|Color|None) -> None:
        ...

    def get_color(self) -> Color|None:
			    ...

A major effort was also made in this version to provide genericity in typing. For example, when defining a decorator, the type of the decorated function is not always known in advance. From now on, ParamSpec can be used to indicate an unknown type.

Further clarity was also introduced with the addition of TypeAlias , which allows giving an explicit name to a complex type.

StrCache: TypeAlias = 'Cache[str]'

To create small functions that verify types (is_bool, is_string, etc.), TypeGuards were introduced. TypeGuards are used for a rather complex practice called type narrowing. The latter is used for functions that accept input variables with several possible types. TypeGuard then allows for type checking to be implemented on the function’s input variables.

🔎The use of TypeGuards is not very simple, so I invite you to look at the Python documentation in more detail if you are interested.

Version 3.11

Continuing with complex additions, version 3.11 introduced Generic Variadics to handle sets of elements with fixed sizes (a tensor with a fixed size, for example).

A generic allows preserving a type between the input and output of a function. For example, if we take a function that copies a variable, it takes a variable as input which can be of any type, so we would type it as Any. Since the function returns a copy of the input variable, the return type of the function will be the same as that of the input variable (Any).

The problem is that with Any, there is no way to verify that the input and output types are the same, and this is where the generic comes into play.

def copy_of_1(value: Any) -> Any: # Le Any d'entrée et 
                                  # le Any de sortie 
                                  # ne sont pas forcés 
                                  # d'être du même type
    return deepcopy(value)


T = TypeVar("T")

def copy_of_2(value: T) -> T:     # Les variables en entrée 
                                  # et en sortie sont forcées 
                                  # d'être du même type
    return deepcopy(value)

Generic Variadics, on the other hand, are more complex and allow for handling multi-dimensional types. 🔎Here again, the implementation is subtle; I invite you to read the official documentation.

However, among the small additions that can be useful to everyone, there are the Required and NotRequired types in TypedDict:

class Shape(TypedDict):
    x: Required[int]
    y: Required[int]
    color: NotRequired[int]

The Self type was also added, which is very practical for creating a constructor:

class Shape:
    @classmethod
    def new(cls, x: int, y: int) -> Self:
        ...

A LiteralString type was also added. It can be used to indicate that a function parameter can be any type of literal string (a string hard-coded in the source, such as “Hello World!”).

This type is primarily useful for enhancing security 💪 because it indicates that a variable must be hard-coded. Unlike a simple str, this type ensures that the value comes directly from the source code without dynamic transformation. Thus, LiteralString establishes an important distinction between strings explicitly defined in the code and those obtained dynamically.

🧐 If you are the type who likes to dig deeper (or if you are simply stubborn like me), here is what I understood about how LiteralString works:

The LiteralString allows you to add a bit of security and rigor to your code without having overly drastic typing. To better visualize this, we can take a log display function as an example.

def log(level: str, message: str):
    if level == "Error":
        print(message)

level = "Error" + "\u200b"  # "Error" avec un caractère invisible (Zero Width Space)
log(level, "Ce message s'affiche !")

With this version of the function, we can see there is a security issue because our message will be displayed even if the level variable is not exactly equal to Error. Not a big deal, you might say. And indeed, it is not very important for a log function, but if it were about managing your databases…

Conversely, we have Literal at the extreme end of typing:

from typing import Literal

ERROR: Literal["Error"] = "Error"

def log(level: Literal["Error"], message: str):
    if level == ERROR:
        print(message)

log(ERROR, "Ce message s'affiche !") # Pas d'alert sur le type

level = "Error" + "\u200b"  # "Erreur" avec un caractère invisible (Zero Width Space)
log(level, "Ce message ne s'affichera pas !") # Une erreur de type sera indiquée

With this version of the function, we strictly control the type of the input level variable. However, as soon as the number of possible input types increases, the syntax becomes laborious. Similarly, it may not be possible to know all possible input types in advance (in the case of external components).

from typing import Literal

INFO: Literal["Info"] = "Info"
WARNING: Literal["Warning"] = "Warning"
ERROR: Literal["Error"] = "Error"

def log(external_component: ???, # Le type n'est pas connu
        level: Literal["Info", "Warning", "Error", ...], # On pourrait avoir beaucoup de types possibles
        message: str):
    if level == ERROR:
        print(message)

To remedy these two situations, the compromise of LiteralString was found:

from typing import LiteralString, Literal

INFO: Literal["Info"] = "Info"
WARNING: Literal["Warning"] = "Warning"
ERROR: Literal["Error"] = "Error"

def log(external_component: LiteralString, level: LiteralString, message: str):
    if level == ERROR:
        print(message)

log(INFO, "Ce message ne s'affichera pas !") # Pas d'alert sur le type
log(WARNING, "Ce message ne s'affichera pas !") # Pas d'alert sur le type
log(ERROR, "Ce message s'affiche !") # Pas d'alert sur le type

It is also possible to use both Literal and LiteralString :

from typing import LiteralString, Literal

INFO: LiteralString = "Info" # On définit INFO avec un type moins précis
WARNING: Literal["Warning"] = "Warning"
ERROR: Literal["Error"] = "Error"

def log(external_component: LiteralString, 
        level: Literal["Warning", "Error"], # Level est soit du type Literal["Warning"] soit du type Literal["Error"]
        message: str):
    if level == ERROR:
        print(message)

log(INFO, "Ce message ne s'affichera pas !") # Alerte sur le type

log(WARNING, "Ce message ne s'affichera pas !") # Pas d'alerte sur le type
log(ERROR, "Ce message s'affiche !") # Pas d'alerte sur le type
log(WARNING+ERROR, "Ce message ne s'affichera pas !") # Pas d'alerte sur le type. Pas très intéressant mais pourquoi pas ...

From the two previous examples, we can conclude that LiteralString groups all Literal[<…>] where <…> is a character string. Thus, we deduce that LiteralString is the supertype of all literal string types.

🚨Therefore, any “subtype” of LiteralString (Literal[“Error”] or even Literal[“Warning”]) is compatible with LiteralString , but not the other way around (refer to the INFO variable in the previous example).

🚨Similarly, the supertype LiteralString is itself a str, making str a super supertype.

🚨Finally, using the same logic as before, we deduce that a str is not compatible with a LiteralString . By this, we mean that it is possible to assign a LiteralString to a str, but not vice versa.

literal_string: LiteralString
s: str = literal_string                 # OK

literal_string: LiteralString = s       # Erreur : 
                                        # On attendait un
                                        # LiteralString, 
                                        # on a un str
literal_string: LiteralString = "hello" # OK

A string created by composing LiteralString typed objects is, for its part, acceptable as a LiteralString (as with Literal).

literal_string_1: LiteralString = "Hello"
literal_string_2: LiteralString = " World"

composed_string: LiteralString = literal_string_1 + literal_string_2 + " !"  # Toujours un LiteralString

This type is useful for sensitive APIs where arbitrary user-generated strings could cause issues.

🔎For more examples, you can refer to the official Python documentation.

Regarding decorators, version 3.11 adds dataclass_transform, which is applicable to a class, a metaclass, or a decorator. This decorator allows marking an object as providing dataclass -like behavior while performing type checking.

As a reminder, the dataclass decorator adds generated and special methods to a class. For example, we will have methods like __init__, __repr__ or __eq__.

# Le décorateur create_model est défini par une bibliothèque.
@typing.dataclass_transform()
def create_model(cls: Type[T]) -> Type[T]:
    cls.__init__ = ...
    cls.__eq__ = ...
    cls.__ne__ = ...
    return cls

# Le décorateur create_model peut désormais être utilisé 
# pour créer de nouvelles classes de modèles :
@create_model
class CustomerModel:
    id: int
    name: str

c = CustomerModel(id=327, name="Eric Idle")

Version 3.12

Version 3.12, for its part, introduces the use of the TypedDict dictionary type to achieve more precise typing of arguments (**kwargs).

Before this addition, **kwargs could be typed provided that all the keyword arguments they specified were of the same type. However, this behavior was very limiting. For example, annotating **kwargs with a str type means that the **kwargs type is actually a dict[str, str] and therefore all keyword arguments in foo are strings.

def foo(**kwargs: str) -> None: ...

Unfortunately, it often happens that keyword arguments passed via **kwargs have different types depending on the keyword name. In such cases, it was not possible to annotate the type of the **kwargs.

Now, by using TypedDict to type **kwargs, it is possible to assign a dictionary as the type for **kwargs. Thus, **kwargs can be typed separately (by dictionary key).

from typing import TypedDict, Unpack

class Movie(TypedDict):
  name: str
  year: int

def foo(**kwargs: Unpack[Movie]): ...

🔎For more details, I invite you to consult the official Python documentation.

Version 3.12 also offers a new override decorator that will undoubtedly be useful for a large majority. The latter indicates that a method in a subclass is intended to override a method (or an attribute) in a parent class.

✨This version also brings new syntactic features to create generic classes and functions in an explicit and compact way.

def max[T](args: Iterable[T]) -> T:
    ...

class list[T]:
    def __getitem__(self, index: int, /) -> T:
        ...

    def append(self, element: T) -> None:
        ...

Additionally, a new way to declare type aliases is introduced. As shown in the following example, the type statement is used, which creates an instance of TypeAliasType and makes the declaration explicit.

type Point = tuple[float, float]

Version 3.13

With version 3.13, it is now possible to define a default value for type parameters (TypeVar, ParamSpec, and TypeVarTuple).

T = TypeVar("T", default=int) # Si aucun type n'est spécifié, 
                              # T sera de type int

@dataclass
class Box(Generic[T]):
    value: T | None = None

reveal_type(Box())                      # Le type est Box[int]
reveal_type(Box(value="Hello World!"))  # Le type est Box[str]

It is also possible, since this version, to mark a class or a function as deprecated using the new deprecated decorator. Thus, developers can be informed when they use these classes and functions so they can implement the necessary migrations.

Another very useful small addition is the ReadOnly qualifier for the TypedDict type, which allows defining certain keys as read-only. The correct use of these read-only keys is intended to be enforced only by static type checkers and not by Python itself at runtime.

Finally, version 3.13 revisits its addition of TypeGuard from version 3.10. This version offers a more intuitive alternative to TypeGuard: TypeIs. This new form allows for the annotation of functions that can be used to refine the type of a value.

from typing import assert_type, final, TypeIs

class Parent: pass
class Child(Parent): pass
@final
class Unrelated: pass

def is_parent(val: object) -> TypeIs[Parent]:
    return isinstance(val, Parent)

def run(arg: Child | Unrelated):
    if is_parent(arg):
        # Le type de ``arg`` est réduit à l'intersection entre 
        # ``Parent`` et ``Child``, 
        # ce qui équivaut à ``Child``.
        assert_type(arg, Child)
    else:
        # Le type de ``arg`` est réduit pour exclure ``Parent``, 
        # de sorte qu'il ne reste que ``Unrelated``.
        assert_type(arg, Unrelated)

Unlike the existing TypeGuard special form, TypeIs can refine the type in both the if and else branches of a condition. However, TypeIs cannot be used when the input and output types are incompatible (for example, list[object] to list[int]), or when the function does not return True for all instances of the narrowed type.

🔎For more details, I refer you to the official documentation.

Conclusion

📈We can see that typing in Python is in full evolution, with each version bringing its share of improvements to make it more expressive, more robust, and easier to use.

Recent additions, such as Self, LiteralString, TypedDict or override, show a desire to make typing more intuitive and useful in concrete scenarios.

With version 3.13, Python continues on this path by offering more flexible tools and readjusting certain decisions, such as the TypeIs alternative for TypeGuard.

👉In summary, typing in Python presents advantages (and also, sometimes, disadvantages) that deserve to be taken into account in your developments.

➕ On one hand, it brings better security by reducing the risk of bugs and attacks. By imposing clear types, it also allows for better code readability, especially when structures and workflows become more complex. This facilitates not only maintenance but also the handover of code to other developers, thus making collaboration smoother.

➖ However, typing in Python also has some limitations. Certain types or features, such as TypeGuard, can be difficult to master. Furthermore, some types, such as LiteralString, do not always bring real added value considering their complexity of use. Finally, the addition of types can sometimes visually clutter the code, which can hinder its readability.

For my part, I believe it is essential to pay attention to typing. It contributes greatly to the understanding and maintenance of code, especially when it comes to taking over someone else’s work. In my view, it is at the very least necessary to type method prototypes, clearly specifying the types of inputs and outputs. Ultimately, the most important thing remains to discuss typing standards with your team in order to adopt a consistent approach adapted to the project’s needs.

Regardless, the Python language is gaining maturity, but typing remains a field of innovation. We look forward to discovering its future evolutions!

P.S.: To learn more about Python’s evolutions (excluding typing), I invite you to consult our article entitled “Discover the major evolutions of Python from 3.9 to 3.13”.


Author: Mathilde Pommier

Articles similaires

ProductsUncategorized

OneGeo Chat v2.2: a redesigned interface, smarter conversations

Uncategorized

Tutorial: Effectively Train My First Neural Network

ProductsUncategorized

Your OneGeo Chat chatbot: a technical breakdown