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 of Gen[T1]
  • Type is contravariance, if Gen[T1] is subtype of Gen[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.