The purpose of this guide is to give you an overview of Python type checking mechanism. The Python language has been able to “annotate” names with type information since version 3.5 (PEP 484 – Type Hints). I recommend reading other blogs if you are interested in learning why static typing is necessary. Nevertheless, I will focus on the practical applications of typing by giving a few examples.
The syntax
Let’s start from the basics of typing syntax:
# annotate that `name` variable will be string
name: str
# assign value to `name` variable
name = "Kamil"
Usually we declare types implicitly and the interpreter guesses what type the variable is:
value = 100
We can also type typical containers such as lists and dictionaries:
users: list[str] = ["Kamil", "Adam"]
scores: dict[str, int] = {"Kamil": 10, "Adam": 20}
We can see that when declaring a dictionary type, we give two arguments, where the first is a key and the second is a value.
You can also combine multiple types using |
operator:
values: list[int | float] = [1, 2.5, 3.0, 4]
Using the |
operator, we created a union of the two types int
and float
. By creating a union of any type and type None
you can mark a variable as optional:
optional_value: int | None = None
And a more advanced example:
scores: dict[str, int | None] = {"Kamil": 10, "Adam": 20, "John": None}
Tuples are a slightly different container. Here, you can define exactly what type each element is:
results: tuple[int, str, float] = 1, "Hello", 10.0
However, you can cleverly use the operator ...
to create a tuple containing elements of one type (without specifying the length of the tuple)
integers: tuple[int, ...] = 1, 2, 3, 4
More complex example:
list_of_tuples: list[dict[str, tuple[int, str]]] = [
{"abc": (1, "abc"), "def": (2, "def")},
{"xyz": (3, "xyz")},
]
Class type hints can be provided by referencing their names as you would any other type:
class Entity:
pass
entities: list[Entity]
In the example above, we indicated that the function takes one value
argument, which is an integer, and also returns an integer.
It is worth mentioning here a container such as TypedDict
. With TypedDict
, it is possible to have a dictionary object with a specific set of string keys and a specific set of values:
from typing import TypedDict
class Author:
def __init__(self, name: str) -> None:
self.name = name
class Movie(TypedDict):
title: str
author: Author
year: int
movie: Movie = {
"title": "Rambo",
"author": Author(name="Sylvester Stallone"),
"year": 1982
}
def display_movie(movie: Movie) -> None:
print(f"Displaying movie: {movie['title']}"
Type checkers can check operations on TypedDict
:
movie["invalid_key"] = 123 # Error: invalid key 'invalid_key'
movie["year"] = "1982" # Error: invalid value type ("int" expected)
Note that Movie
in example above is also class
, so you can use class constructor to create movie dictionary:
blade_runner = Movie(
name="Blade Runner",
author=Author(name="Luke Scott"),
year=1982
)
print(type(blade_runner)) # <class 'dict'>
Alternatively, you can define Movie
dict using other syntax:
Movie = TypedDict("Movie'", {"name": str, "author": Author, "year": int})
Note that in some situations, we want to mark some fields as not required (this is a new feature from python 3.11), example:
from typing import NotRequired
class Restaurant(TypedDict):
name: str
score: NotRequired[int]
best_restaurant: Restaurant = {"name": "The best restaurant", "score": 10}
another_restaurant: Restaurant = {"name": "Another"}
Note that you can also try to mark score
field as Optional
, but NotRequired
mark potentially-missing keys in Restaurant
dictionary. You can read more about TypedDict
in PEP 589 and PEP 655.
With this basic knowledge, we can try to add annotations to the functions:
def double(value: int) -> int:
return value * 2
If you want a function to return nothing, you can mark it as -> None
:
def nothing() -> None:
...
It is also possible to pass other numbers, such as float, to the function double
. In that case, we can create a TypeAlias
for integers and floats.
from typing import TypeAlis
Number: TypeAlias = int | float
def double(value: Number) -> Number:
return value * 2
Why it’s useful? You can use TypeAlias
instead of typing the same type combination all the time:
def add(a: int | float, b: int | float) -> int | float:
return a + b
def add(a: Number, b: Number) -> Number:
return a + b
Many times, your functions will expect some kind of sequence, and not really care if it’s a list or a tuple:
from typing import Sequence
def process(values: Sequence[int]) -> int:
...
But in many cases you can also use Iterable
type hint:
from typing import Iterable
def process(values: Iterable[int]) -> int:
...
What’s difference? Python Iterable vs Sequence → so list
, tuple
are sequences.
There are also situations when your function can take any input, then you should use the type Any
:
from typing import Any
def count(values: Sequence[Any]) -> int:
return len(values)
However, by using Any
we lose all control over the types.
In Python, functions are first-class types, which means they can also be objects, i.e. they can have their own types.
from typing import Callable
def evaluate(value: int, evaluator: Callable[[int], bool]) -> bool:
return evaluator(value)
def is_positive(value: int) -> bool:
return value > 0
result = evaluate(value=10, evaluator=is_positive)
Callable
is a generic type, that takes the argument list (or ellipsis) and the return type:
function: Callable[..., ReturnType]
For example, function takes two arguments age: int
, name: str
and returns User
type you should write:
def process(age: int, name: str) -> User:
return User(age, name)
function: Callable[[int, str], User] = process
You can use Callable
generic to type lambda functions:
sum_values: Callable[[Sequence[Number]], Number] = lambda values: sum(values)
Python makes it difficult to define constants, we usually use uppercase letters. However, using the Final
annotation, we can mark a variable as constant:
from typing import Final
API_URL: Final[str] = "localhost:8000/api/v1/"
Typing in own classess
Classes and types are related. All vehicle instances are Vehicle
type:
class Vehicle:
def __init__(self, model: str) -> None:
self.model = model
You can add typing to that returns class instance by using Self
type, which is available from python 3.11
and in typing
library:
class Human:
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
@classmethod
def create_baby(cls, name: str) -> Self:
return cls(name=name, age=0)
Adding typing to properties and methods is also possible:
class Human:
def __init__(self, name: str, age: int) -> None:
self._name = name
self._age = age
@classmethod
def create_baby(cls, name: str) -> Self:
return cls(name=name, age=0)
def get_age() -> int:
return self._age
@property
def age_in_days() -> int:
return self.get_age() * 365
You can also mark class variables using ClassVar
:
from typing import ClassVar
class Gateway:
api_url: ClassVar[str] = "localhost:8000/api/v1/"
gateway = Gateway()
gateway.api_url = "localhost:8000" # Error, setting class variable on instance
Gateway.api_url = "localhost:8000" # This is OK
Advanced Types
Let’s move on to more advanced examples. The first thing we’ll do is learn about generic types and variables used in typing.
What is type variable? From official documentation:
from typing import TypeVar
T = TypeVar("T") # Can be anything
S = TypeVar("S", bound=str) # Can be any subtype of str
A = TypeVar("A", str, bytes) # Must be exactly str or bytes
The primary purpose of type variables is to assist static type checkers. The parameters of generic types and generic functions are defined by them:
def ten_times(element: T) -> Sequence[T]:
return [element for _ in range(10)]
Numeric = TypeVar("Numeric", bound=int | float)
def double(value: Numeric) -> Numeric:
return value * 2
You can use type variable T
to annotate that the function takes two arguments of the same type:
def add(a: T, b: T) -> T:
return a + b
It is also possible to define a function with multiple types of variables:
K = TypeVar("K")
V = TypeVar("V")
values: dict[K, V] = {}
def set_value(key: K, value: V) -> V:
values[key] = value
return value
Note that K
type should be hashable type — we’ll come back to this when we discuss Protocols
, but for now it’s ok.
Our next example is to create a function map that takes another function as an argument and maps values using a specified function:
Input = TypeVar("Input")
Output = TypeVar("Output")
def maps(func: Callable[[Input], Output], values: Iterable[Input]) -> list[Output]:
results: list[Output] = []
for value in values:
results.append(func(value))
return results
Type variables allow you to create generic types. What is it?
A generic type is a class or interface that is parameterized over types. Variance refers to how subtyping between the generic types relates to subtyping between their parameters' types.
Please note that any previously used container type was a generic type, e.g. list[T]
is a generic type and the list[int]
is a specified list
type that accepts only int
values — this special square brackets syntax denotes that it’s generic. Also types such as Callable
are generics.
Now let’s see how we can define generics ourselves. Generically, a generic type is declared by inheriting from an instantiation of this class. It is possible to define a generic container type as follows:
T = TypeVar("T")
class Container(Generic[T]):
values: list[T]
def __init__(self) -> None:
self.values = []
def add(value: T) -> None:
self.values.append(value)
def get_last() -> T:
return self.values[-1]
container = IntContainer()
container.add(1)
container.add(2)
container.get_last() # returns 2
Or more complex example:
TKey = TypeVar("TKey") # KT type should be hashable
TVal = TypeVar("TVal") # VT can be anything
class FrozenDictionary(Generic[TKey, TVal]):
def __init__(self, values: dict[TKey, TVal]) -> None:
self._values: dict[TKey, TVal] = values
def __getitem__(self, key: TKey) -> TVal:
return self._values[key]
def __contains__(self, key: TKey) -> bool:
return key in self._values
def values(self) -> Iterable[TVal]:
return self._values.values()
Using predefined generic types:
from collections.abc import Mapping
class Client: ...
class Order: ...
def prepare_orders(orders: Mapping[Client, Order]) -> None:
...
You can use multiple inheritance while creating custom generic types:
from collections.abc import Sized
T = TypeVar("T")
class SizedList(Sized, Generic[T]):
...
Inheriting from generic classes may result in the following type variables being fixed:
from collections.abc import Mapping
class Country:
...
T = TypeVar("T")
class ValuesForCountries(Mapping[Country, T]):
...
You can create more complex generic types with any number of type variables, example from typing documentation:
T = TypeVar('T', contravariant=True)
B = TypeVar('B', bound=Sequence[bytes], covariant=True)
S = TypeVar('S', int, str)
class WeirdTrio(Generic[T, B, S]):
...
It is worth mentioning here about covariance and contravariance. If T2
is subtype of T1
, then generic type Gen
will be:
- Type is covariance, if
Gen[T2]
is subtype ofGen[T1]
- Type is contravariance, if
Gen[T1]
is subtype ofGen[T2]
- Type is invariant, if neither of the above is true.
By default, generic types are considered invariant in all type variables, which means that values for variables annotated with types like List[Employee]
must exactly match the type annotation – no subclasses (e.g. class Manager
) or superclasses (e.g. class People
) of the type parameter are allowed.
Example of covariance:
class Animal:
pass
class Cat(Animal):
pass
def do_something(animal: Iterable[Animal]) -> None:
...
animals = [Animal()]
cats = [Cat()]
do_something(animals) # OK ✅
do_something(cats) # OK ✅
def do_something_for_cats(cats: Iterable[Cat]) -> None:
...
do_something_for_cats(animals) # NOT OK
do_something_for_cats(cats) # OK ✅
do_something_for_cats(animals)
is wrong for type checker, because do_something_for_cats
expects iterable sequence of Cat
instances (or subtype of it).
Type variables accept keyword arguments covariant=True
or contravariant=True
for declaring container types that support covariant or contravariant type checking. From official typing documentation:
T_co = TypeVar("T_co", covariant=True)
class ImmutableList(Generic[T_co]):
def __init__(self, items: Iterable[T_co]) -> None: ...
def __iter__(self) -> Iterator[T_co]: ...
class Employee: ...
class Manager(Employee): ...
def dump_employees(emps: ImmutableList[Employee]) -> None:
for emp in emps:
...
mgrs = ImmutableList([Manager()]) # type: ImmutableList[Manager]
dump_employees(mgrs) # OK
We won’t focus on these examples further, you can read more here in PEP 484.
Protocols and structural subtyping
In Python, structural subtyping is natural because it matches duck typing’s runtime semantics: Certain properties of an object are treated independently of its actual runtime class. As a result, we are not interested in what an object is, but in how it can be used.
Protocol
is used to specify the tasks and operations that a given object is able to perform. Here is a simple example:
from typing import Protocol
class PSayHello(Protocol):
def hello(self) -> None:
...
Protocol
acts as an interface, it defines what an object must do, but it doesn’t say how. Example implementation of SayHello
:
class HelloWorld:
def hello(self) -> None:
print("Hello world! 🌎")
And now we can create function that accepts as an argument SayHelloProto
:
def foo(say_hello: PSayHello) -> None:
say_hello.hello()
For the sake of clarity, we will discuss one more advanced example of using protocols. Example from PEP 544 documentation:
class SupportsClose(Protocol):
def close(self) -> None:
... # Empty method body
class Resource: # No inheritance -> No SupportsClose base class!
# ... some methods ...
def close(self) -> None:
self.resource.release()
def close_all(items: Iterable[SupportsClose]) -> None:
for item in items:
item.close()
close_all([Resource(), open("some/file")]) # Okay!
In the example above, having a compatible closing method makes Resource
a subtype of SupportsClose
. Please note that Resource
class doesn’t have explicit inheritance from SupportsClose
.
You can define subprotocols and subclassing protocols. Example from mypy documentation:
class SupportsRead(Protocol):
def read(self) -> bytes:
...
class TaggedReadableResource(SupportsClose, SupportsRead, Protocol):
label: str
class AdvancedResource(Resource):
def __init__(self, label: str) -> None:
self.label = label
def read(self) -> bytes:
# some implementation
...
resource: TaggedReadableResource
resource = AdvancedResource("handle with care") # OK
Protocol classes can be generic, for example:
class MyGenericProtocol(Protocol[T]):
def value(self) -> T:
...
class IntValue:
def value(self) -> int:
return 10
Previously, we’ve used generic protocols like: Sequence
, Iterable
and Sized
.
# object is Iterable if have defined __iter__() method:
class Iterable(Protocol[T]):
@abstractmethod
def __iter__(self) -> Iterator[T]:
...
class IntList:
def __init__(self, value: int, next: Optional['IntList']) -> None:
self.value = value
self.next = next
def __iter__(self) -> Iterator[int]:
current = self
while current:
yield current.value
current = current.next
Note that all predefined protocols must have some abstract method, e.g.
Iterable[T]
→__iter__()
Sized[T]
→__len__()
Container[T]
→__contains__()
There are several predefined protocols that can also be used as mixins to make implementing container APIs easier. All predefined protocols can be found here. Note that you can merge and extend protocols, for example:
from typing import Sized
class SizedAndClosable(Sized, SupportsClose, Protocol):
def close(self) -> None:
...
PEP 544 provides more information about protocols, which are a very complex and extensive topic.
What’s new in Python 3.11?
The Self
type. Annotating methods that return objects to which they belong is made easy using this type:
from typing import Self
class Shape:
def __init__(self, scale: float) -> None:
self._scale = scale
# usage of Self in return type
def set_scale(self, scale: float) -> Self:
self._scale = scale
return self
def get_scale(self) -> float:
return self
# usage of Self in parameter type
# you can use Self to annotate parameters
# that expect instances of the current class
def __add__(self, other: Self) -> Self:
return self.get_scale() + other.get_scale()
Note that specifying self: Self
is harmless, so some users may find it more readable to write the above as:
class Shape:
def __init__(self: Self, scale: float) -> None:
self._scale = scale
def set_scale(self: Self, scale: float) -> Self:
self._scale = scale
return self
# etc.
PEP 673 provides more information.
Let’s move to “Arbitrary Literal String Type” which was introduced in PEP 675. As the documentation for this PEP is well written, I am not going to write about it here. I will only give here an example of using LiteralString
:
def foo(literal_string: LiteralString) -> None:
...
foo("Hello world") # OK ✅
name = "Kamil"
foo(name) # Error: Expected LiteralString, got str.
Full changelog can be found in PEP 664.
Additional
How to type *args
, **kwargs
? args
and kwargs
have always been a problem for me. However, we can find the answer in PEP 484:
Arbitrary argument lists can as well be type annotated, so that the definition:
def foo(*args: str, **kwargs: int): ...
is acceptable and it means that, e.g., all of the following represent function calls with valid types of arguments:
foo("a", "b", "c")
foo(x=1, y=2)
foo("a", z=0)
Variables args
and kwargs
in function foo
are deduced to be tuple[str, ...]
and dict[str, int]
respectively.