XClose

Research Software Engineering Summer School

Home
Menu

Static Typing

Python is a dynamically types and interpreted language, but over the years Python community has developed tools and frameworks to add (non-strict) type hints to Python code.

These type hints, even though not strict, offer users a much clear picture of how the code is supposed to be used. Besides offering help to the users, type hints also improve documentation, as the leading static site generators can pick type hints from your code and render them in the documentation automatically.

In addition to the UX improvements, type hints often help developers catch silent bugs, dead code, or missing functionalities in downstream code. Further libraries like mypyc can compile Python modules to C extensions by simply using the type hints added by developers, offering a speedup in your standard Python code.

Looking at types

Let's see how types can be defined for Python variables and function signatures.

In [1]:
a: int = 5  # a is of type int
In [2]:
def add(a: int, b: int) -> int:  # takes in 2 `int`s and returns an `int`
    return a + b
In [3]:
def hello(name: str) -> None:  # takes in a `str` and return `None`
    print("hello", name)
    
# But are these types rigid?
In [4]:
type(a)  # declared `int` above
Out[4]:
int
In [5]:
a = 5.5
In [6]:
type(a)
Out[6]:
float

No! In fact, the typing information printed by type() is not gathered through the type hint, instead, it uses the type assigned to the variable dynamically by Python interpretor. So how does the the typing information help us?

Static type checkers

Static type checkers can be used to verify if the codebase is adhering to the type hints declared by the developers. There are a number of tools and frameworks available for checking type information in the Python ecosystem:

  • mypy: a static type checker for Python
  • pytype: checks and infers types for Python code - without requiring type annotations
  • pyright: a full-featured, standards-compliant static type checker for Python
  • pyre: a performant type-checker for Python 3

Mypy is one of the oldest open-sourced and the most widely used static type checker for Python code. The tool is also recommended by Scientific Python, so our examples below will use mypy, but feel free to experiment with the other tools as well. Additionally, most of the IDEs either provide integration support for the static typing tools listed above or offer their own solutions for checking static types.

Mypy

We can write the same code in a file and run mypy over it to check the correctness of types:

In [7]:
%%writefile static_types_example.py
a: int = 5
a = 5.5
Overwriting static_types_example.py
In [8]:
%%bash
mypy static_types_example.py
bash: line 1: mypy: command not found
---------------------------------------------------------------------------
CalledProcessError                        Traceback (most recent call last)
Cell In[8], line 1
----> 1 get_ipython().run_cell_magic('bash', '', 'mypy static_types_example.py\n')

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/interactiveshell.py:2541, in InteractiveShell.run_cell_magic(self, magic_name, line, cell)
   2539 with self.builtin_trap:
   2540     args = (magic_arg_s, cell)
-> 2541     result = fn(*args, **kwargs)
   2543 # The code below prevents the output from being displayed
   2544 # when using magics with decorator @output_can_be_silenced
   2545 # when the last Python token in the expression is a ';'.
   2546 if getattr(fn, magic.MAGIC_OUTPUT_CAN_BE_SILENCED, False):

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/magics/script.py:155, in ScriptMagics._make_script_magic.<locals>.named_script_magic(line, cell)
    153 else:
    154     line = script
--> 155 return self.shebang(line, cell)

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/magics/script.py:315, in ScriptMagics.shebang(self, line, cell)
    310 if args.raise_error and p.returncode != 0:
    311     # If we get here and p.returncode is still None, we must have
    312     # killed it but not yet seen its return code. We don't wait for it,
    313     # in case it's stuck in uninterruptible sleep. -9 = SIGKILL
    314     rc = p.returncode or -9
--> 315     raise CalledProcessError(rc, cell)

CalledProcessError: Command 'b'mypy static_types_example.py\n'' returned non-zero exit status 127.

Mypy correctly points out that we are reassigning a to a floating point number, but it was declared as an integer on line 1. How about function calls?

In [9]:
%%writefile static_types_example.py
def add(a: int, b: int) -> int:
    return a + b

def hello(name: str) -> None:
    print("hello", name)

add(1, 2)
hello("Saransh")
add(1.5, 2)
hello(5)
Overwriting static_types_example.py
In [10]:
%%bash
mypy static_types_example.py
bash: line 1: mypy: command not found
---------------------------------------------------------------------------
CalledProcessError                        Traceback (most recent call last)
Cell In[10], line 1
----> 1 get_ipython().run_cell_magic('bash', '', 'mypy static_types_example.py\n')

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/interactiveshell.py:2541, in InteractiveShell.run_cell_magic(self, magic_name, line, cell)
   2539 with self.builtin_trap:
   2540     args = (magic_arg_s, cell)
-> 2541     result = fn(*args, **kwargs)
   2543 # The code below prevents the output from being displayed
   2544 # when using magics with decorator @output_can_be_silenced
   2545 # when the last Python token in the expression is a ';'.
   2546 if getattr(fn, magic.MAGIC_OUTPUT_CAN_BE_SILENCED, False):

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/magics/script.py:155, in ScriptMagics._make_script_magic.<locals>.named_script_magic(line, cell)
    153 else:
    154     line = script
--> 155 return self.shebang(line, cell)

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/magics/script.py:315, in ScriptMagics.shebang(self, line, cell)
    310 if args.raise_error and p.returncode != 0:
    311     # If we get here and p.returncode is still None, we must have
    312     # killed it but not yet seen its return code. We don't wait for it,
    313     # in case it's stuck in uninterruptible sleep. -9 = SIGKILL
    314     rc = p.returncode or -9
--> 315     raise CalledProcessError(rc, cell)

CalledProcessError: Command 'b'mypy static_types_example.py\n'' returned non-zero exit status 127.

Mypy correctly points out that we are using the functions wrong!

Mypy includes the functions reveal_type() and reveal_locals() to check the type of variables programatically on run time. These functions should not be used when running your modules through the Python interpretor, but they can be used when you are running mypy over your modules.

In [11]:
%%writefile static_types_example.py
def add(a: int, b: int) -> int:
    return a + b

def hello(name: str) -> None:
    print("hello", name)

a = add(1, 2)
reveal_type(a)

b = hello("Saransh")
reveal_type(b)

c = 5
reveal_locals()
Overwriting static_types_example.py
In [12]:
%%bash
mypy static_types_example.py
bash: line 1: mypy: command not found
---------------------------------------------------------------------------
CalledProcessError                        Traceback (most recent call last)
Cell In[12], line 1
----> 1 get_ipython().run_cell_magic('bash', '', 'mypy static_types_example.py\n')

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/interactiveshell.py:2541, in InteractiveShell.run_cell_magic(self, magic_name, line, cell)
   2539 with self.builtin_trap:
   2540     args = (magic_arg_s, cell)
-> 2541     result = fn(*args, **kwargs)
   2543 # The code below prevents the output from being displayed
   2544 # when using magics with decorator @output_can_be_silenced
   2545 # when the last Python token in the expression is a ';'.
   2546 if getattr(fn, magic.MAGIC_OUTPUT_CAN_BE_SILENCED, False):

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/magics/script.py:155, in ScriptMagics._make_script_magic.<locals>.named_script_magic(line, cell)
    153 else:
    154     line = script
--> 155 return self.shebang(line, cell)

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/magics/script.py:315, in ScriptMagics.shebang(self, line, cell)
    310 if args.raise_error and p.returncode != 0:
    311     # If we get here and p.returncode is still None, we must have
    312     # killed it but not yet seen its return code. We don't wait for it,
    313     # in case it's stuck in uninterruptible sleep. -9 = SIGKILL
    314     rc = p.returncode or -9
--> 315     raise CalledProcessError(rc, cell)

CalledProcessError: Command 'b'mypy static_types_example.py\n'' returned non-zero exit status 127.

typing, types, and collections

The typing module of Python offers the building blocks for type annotations, such as Any and Never, advanced features, such as Protocol, TypeVar, NewType, Generic and TypeAlias, and useful functions, such as reveal_type().

Similarly, the types module offers additional built-in types, such as the NoneType, LambdaType, and ModuleType. Besides typing and types, the collections.abc offers abstract base classes for data containers, such as Sequence, Iterable, Mapping, and Set.

Let's code up a dummy calculator class to see some of the typing annotations in action.

In [13]:
%%writefile static_types_example.py
class Calculator:
    def __init__(self, a):
        self.a = a
    def add(self, b):
        return self.a + b
    def add_multi(self, *b):
        return sum((self.a, *b))
    def subtract(self, b):
        return self.a - b
    def multiply(self, b):
        return self.a * b
    def divide(self, b, check_zero=True):
        if check_zero and b == 0:
            return None
        return self.a / b
    def transform(self, tfm):
        return tfm(self.a)
    def idx(self, sqnc):
        if isinstance(a, float):
            raise ValueError("a should be int")
        try:
            rslt = sqnc[a]
        except IndexError:
            raise ValueError("sequence too small")
        return rslt
Overwriting static_types_example.py

The class can be updated with basic typing information.

In [14]:
%%writefile static_types_example.py
from collections.abc import Callable
from typing import Union, Optional, Any

class Calculator:
    def __init__(self, a: Union[int, float]) -> None:
        self.a = a
    def add(self, b: Union[int, float]) -> Union[int, float]:
        return self.a + b
    # *args and **kwargs need type annotation for what each argument
    # can be
    def add_multi(self, *b: Union[int, float]) -> Union[int, float]:
        return sum((self.a, *b))
    def subtract(self, b: Union[int, float]) -> Union[int, float]:
        return self.a - b
    def multiply(self, b: Union[int, float]) -> Union[int, float]:
        return self.a * b
    def divide(self, b: Union[int, float], check_zero: Optional[bool] = True) -> Optional[Union[int, float]]:
        if check_zero and b == 0:
            return None
        return self.a / b
    # tfm is a function (`Callable`) that takes an `int` or `float`
    # as an argument and can output anything (`Any`)
    def transform(self, tfm: Callable[[Union[int, float]], Any]):
        return tfm(self.a)
    def idx(self, sqnc):
        if isinstance(a, float):
            raise ValueError("a should be int")
        try:
            rslt = sqnc[a]
        except IndexError:
            raise ValueError("sequence too small")
        return rslt
Overwriting static_types_example.py
In [15]:
%%bash
mypy static_types_example.py
bash: line 1: mypy: command not found
---------------------------------------------------------------------------
CalledProcessError                        Traceback (most recent call last)
Cell In[15], line 1
----> 1 get_ipython().run_cell_magic('bash', '', 'mypy static_types_example.py\n')

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/interactiveshell.py:2541, in InteractiveShell.run_cell_magic(self, magic_name, line, cell)
   2539 with self.builtin_trap:
   2540     args = (magic_arg_s, cell)
-> 2541     result = fn(*args, **kwargs)
   2543 # The code below prevents the output from being displayed
   2544 # when using magics with decorator @output_can_be_silenced
   2545 # when the last Python token in the expression is a ';'.
   2546 if getattr(fn, magic.MAGIC_OUTPUT_CAN_BE_SILENCED, False):

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/magics/script.py:155, in ScriptMagics._make_script_magic.<locals>.named_script_magic(line, cell)
    153 else:
    154     line = script
--> 155 return self.shebang(line, cell)

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/magics/script.py:315, in ScriptMagics.shebang(self, line, cell)
    310 if args.raise_error and p.returncode != 0:
    311     # If we get here and p.returncode is still None, we must have
    312     # killed it but not yet seen its return code. We don't wait for it,
    313     # in case it's stuck in uninterruptible sleep. -9 = SIGKILL
    314     rc = p.returncode or -9
--> 315     raise CalledProcessError(rc, cell)

CalledProcessError: Command 'b'mypy static_types_example.py\n'' returned non-zero exit status 127.

Notice how mypy does not error out on missing types for transform(). This is because mypy supports gradual typing.

We can update the type hints to be more modern:

In [16]:
%%writefile static_types_example.py
from typing import Any
from collections.abc import Callable

class Calculator:
    def __init__(self, a: int | float) -> None:
        self.a = a
    def add(self, b: int | float) -> int | float:
        return self.a + b
    def add_multi(self, *b: int | float) -> int | float:
        return sum((self.a, *b))
    def subtract(self, b: int | float) -> int | float:
        return self.a - b
    def multiply(self, b: int | float) -> int | float:
        return self.a * b
    def divide(self, b: int | float, check_zero: bool | None = True) -> int | float | None:
        if check_zero and b == 0:
            return None
        return self.a / b
    def transform(self, tfm: Callable[[int | float], Any]):
        return tfm(self.a)
    def idx(self, sqnc):
        if isinstance(a, float):
            raise ValueError("a should be int")
        try:
            rslt = sqnc[a]
        except IndexError:
            raise ValueError("sequence too small")
        return rslt
Overwriting static_types_example.py

Type aliases and generic types

Or dig into some advanced concepts like TypeAlias and TypeVar.

In [17]:
%%writefile static_types_example.py
from typing import TypeAlias, Union, TypeVar, Any
from collections.abc import Sequence, Callable

number: TypeAlias = Union[int, float]
# or just
# number = int | float
T = TypeVar("T")

class Calculator:
    def __init__(self, a: number) -> None:
        self.a = a
    def add(self, b: number) -> number:
        return self.a + b
    def add_multi(self, *b: number) -> number:
        return sum((self.a, *b))
    def subtract(self, b: number) -> number:
        return self.a - b
    def multiply(self, b: number) -> number:
        return self.a * b
    def divide(self, b: number, check_zero: bool | None = True) -> number | None:
        if check_zero and b == 0:
            return None
        return self.a / b
    def transform(self, tfm: Callable[[number], Any]):
        return tfm(self.a)
    # takes a Sequence with each element of some type T, and returns
    # a variable of the type T
    def idx(self, sqnc: Sequence[T]) -> T:
        if isinstance(self.a, float):
            raise ValueError("a should be int")
        try:
            rslt = sqnc[self.a]
        except IndexError:
            raise ValueError("sequence too small")
        return rslt
Overwriting static_types_example.py
In [18]:
%%bash
mypy static_types_example.py
bash: line 1: mypy: command not found
---------------------------------------------------------------------------
CalledProcessError                        Traceback (most recent call last)
Cell In[18], line 1
----> 1 get_ipython().run_cell_magic('bash', '', 'mypy static_types_example.py\n')

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/interactiveshell.py:2541, in InteractiveShell.run_cell_magic(self, magic_name, line, cell)
   2539 with self.builtin_trap:
   2540     args = (magic_arg_s, cell)
-> 2541     result = fn(*args, **kwargs)
   2543 # The code below prevents the output from being displayed
   2544 # when using magics with decorator @output_can_be_silenced
   2545 # when the last Python token in the expression is a ';'.
   2546 if getattr(fn, magic.MAGIC_OUTPUT_CAN_BE_SILENCED, False):

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/magics/script.py:155, in ScriptMagics._make_script_magic.<locals>.named_script_magic(line, cell)
    153 else:
    154     line = script
--> 155 return self.shebang(line, cell)

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/magics/script.py:315, in ScriptMagics.shebang(self, line, cell)
    310 if args.raise_error and p.returncode != 0:
    311     # If we get here and p.returncode is still None, we must have
    312     # killed it but not yet seen its return code. We don't wait for it,
    313     # in case it's stuck in uninterruptible sleep. -9 = SIGKILL
    314     rc = p.returncode or -9
--> 315     raise CalledProcessError(rc, cell)

CalledProcessError: Command 'b'mypy static_types_example.py\n'' returned non-zero exit status 127.

Duck Typing and Protocols

Duck Typing is often referred to as "If it walks like a duck and it quacks like a duck, then it must be a duck." Languages like Python can treat a variable of a given type if it implements all the methods properties implemented/required by the type.

For instance, the len method can be called in a similar manner on the built-in list type, or on out custom class:

In [19]:
class Container:
    def __len__(self):
        return 5
In [20]:
c = Container()
len(c)
Out[20]:
5

A good example of duck typing is how one can use same standard library functions on tuples, lists, strings, sets, and dictionaries without explicitly telling the function the type of the argument.

In [21]:
lst = [1, 2, 3]
tpl = (1, 2, 3)
st = {1, 2, 3}
strng = "123"
dct = {1: 1, 2: 2, 3: 3}
In [22]:
sorted(lst, reverse=True)
Out[22]:
[3, 2, 1]
In [23]:
sorted(tpl, reverse=True)
Out[23]:
[3, 2, 1]
In [24]:
sorted(st, reverse=True)
Out[24]:
[3, 2, 1]
In [25]:
sorted(strng, reverse=True)
Out[25]:
['3', '2', '1']
In [26]:
sorted(dct, reverse=True)
Out[26]:
[3, 2, 1]

Thanks to duck typing, one usually does not need to deal with Abstract Base Classes or Interfaces) in Python, but a Protocols are often useful for subtyping Python classes.

Protocols allow multiple classes to act as the same type if they implement the same methods or "protocol members". Let's construct a Protocol subclass and 2 other classes with the same method.

In [27]:
%%writefile static_types_example.py
from typing import Protocol

class BaseClass(Protocol):
    def __len__(self) -> int: ...


class A:
    def __len__(self) -> int:
        return 5

class B:
    def __len__(self) -> int:
        return 6
Overwriting static_types_example.py

We can then define a function that takes an argument of type BaseClass and prints its length. Let's call it on a bunch of arguments.

In [28]:
%%writefile static_types_example.py
from typing import Protocol

class BaseClass(Protocol):
    def __len__(self) -> int: ...


class A:
    def __len__(self) -> int:
        return 5

class B:
    def __len__(self) -> int:
        return 6


def f(el: BaseClass) -> None:
    print(len(el))


a = A()
b = B()

f(a)
f(b)
Overwriting static_types_example.py
In [29]:
%%bash
mypy static_types_example.py
bash: line 1: mypy: command not found
---------------------------------------------------------------------------
CalledProcessError                        Traceback (most recent call last)
Cell In[29], line 1
----> 1 get_ipython().run_cell_magic('bash', '', 'mypy static_types_example.py\n')

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/interactiveshell.py:2541, in InteractiveShell.run_cell_magic(self, magic_name, line, cell)
   2539 with self.builtin_trap:
   2540     args = (magic_arg_s, cell)
-> 2541     result = fn(*args, **kwargs)
   2543 # The code below prevents the output from being displayed
   2544 # when using magics with decorator @output_can_be_silenced
   2545 # when the last Python token in the expression is a ';'.
   2546 if getattr(fn, magic.MAGIC_OUTPUT_CAN_BE_SILENCED, False):

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/magics/script.py:155, in ScriptMagics._make_script_magic.<locals>.named_script_magic(line, cell)
    153 else:
    154     line = script
--> 155 return self.shebang(line, cell)

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/magics/script.py:315, in ScriptMagics.shebang(self, line, cell)
    310 if args.raise_error and p.returncode != 0:
    311     # If we get here and p.returncode is still None, we must have
    312     # killed it but not yet seen its return code. We don't wait for it,
    313     # in case it's stuck in uninterruptible sleep. -9 = SIGKILL
    314     rc = p.returncode or -9
--> 315     raise CalledProcessError(rc, cell)

CalledProcessError: Command 'b'mypy static_types_example.py\n'' returned non-zero exit status 127.

mypy does not error out! This is because A and B are "structural" subtypes of BaseClass or is an "implementation" of the Protocol.

Do you think the function will accept aa dictionary or a list as an input without mypy complaining?

In [30]:
%%writefile static_types_example.py
from typing import Protocol

class BaseClass(Protocol):
    def __len__(self) -> int: ...


class A:
    def __len__(self) -> int:
        return 5

class B:
    def __len__(self) -> int:
        return 6


def f(el: BaseClass) -> None:
    print(len(el))


a = A()
b = B()

f(a)
f(b)
f({1: 1, 2: 2})
f([1, 2])
Overwriting static_types_example.py
In [31]:
%%bash
mypy static_types_example.py
bash: line 1: mypy: command not found
---------------------------------------------------------------------------
CalledProcessError                        Traceback (most recent call last)
Cell In[31], line 1
----> 1 get_ipython().run_cell_magic('bash', '', 'mypy static_types_example.py\n')

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/interactiveshell.py:2541, in InteractiveShell.run_cell_magic(self, magic_name, line, cell)
   2539 with self.builtin_trap:
   2540     args = (magic_arg_s, cell)
-> 2541     result = fn(*args, **kwargs)
   2543 # The code below prevents the output from being displayed
   2544 # when using magics with decorator @output_can_be_silenced
   2545 # when the last Python token in the expression is a ';'.
   2546 if getattr(fn, magic.MAGIC_OUTPUT_CAN_BE_SILENCED, False):

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/magics/script.py:155, in ScriptMagics._make_script_magic.<locals>.named_script_magic(line, cell)
    153 else:
    154     line = script
--> 155 return self.shebang(line, cell)

File /opt/hostedtoolcache/Python/3.12.8/x64/lib/python3.12/site-packages/IPython/core/magics/script.py:315, in ScriptMagics.shebang(self, line, cell)
    310 if args.raise_error and p.returncode != 0:
    311     # If we get here and p.returncode is still None, we must have
    312     # killed it but not yet seen its return code. We don't wait for it,
    313     # in case it's stuck in uninterruptible sleep. -9 = SIGKILL
    314     rc = p.returncode or -9
--> 315     raise CalledProcessError(rc, cell)

CalledProcessError: Command 'b'mypy static_types_example.py\n'' returned non-zero exit status 127.

Yes, it does! Technically, dict and list are also implementations of our Protocol as they include a __len__() method.

Static typing is a very powerful tool to have in your arsenal while writing Python code. In the next few chapters, we will see how static types can help us make better design choices (we saw some of it above) and even test our code to detect things like silent bugs or dead lines.

Further reading: