Type Hinting
Here are some examples and basics on typing. More information on the why and how to run type checking can be found in our style guide This is just a summary and some common examples, but more examples on Type Hinting in Python can be found in the documentation for Python and mypy also has a very helpful Cheatsheet
Basics
You can type any variable at or before its definition by placing a :
followed by the type after
it, eg. potato: Potato
,
from idaho import Potato
potato: Potato = Potato() # Just an example, the type hint here is not required.
Any built-in type or class can be used as a type annotation as well as some specialized classes classed Generics
You can type the return value with a ->
followed by the type between the end of the method and the
:
, eg def return_potato() -> Potato: ...
. If the function does not return anything
def my_method(db: Database) -> None:
self.db: Database = db
If there are no return
statements or values, methods will still transparently return None
behind
the scenes and thus should be typed with -> None
. However, if a method always raise an exception
you can use the special NoReturn
type which indicates the code after will be inaccessible.
from typing import NoReturn
def fail() -> NoReturn:
raise Exception('This will never return anything')
def do_nothing() -> None:
pass # This method does nothing, but still "returns" None as it does not raise an exception.
class MyClass:
def __init__(self) -> None: # __init__ returns None
...
Typing attributes
class MyClass:
default_number: int = 1
will_be_set_later: str = None # type: ignore[assignment]
def __init__(self, timeout: int = 15) -> None:
self.timeout = timeout # No need to type this as it is inferred from above
self.names: list[str] = [] # when creating collections, it's important to type future contents
self.setup()
def setup(self) -> None:
self.will_be_set_later = 'hello'
Multiple types (Unions)
If multiple types are accepted they can be specified using the generic Union
, which can also be
more succinctly expressed using the pipe symbol |
as of python 3.10. Note: this can still be
used on older version of Python in annotation as long as you use
lazy annotations, and it is not assigned to a variable.
from __future__ import annotations
from typing import Union
alphanumeric_list: list[int | str] = ['a', 1]
alphanumeric_list: list[Union[int, str]] = ['a', 1]
Type classes vs instances
Using a class as the type, it indicates that the variable is an instance of said class. If you
instead wish to say that the variable is the class itself, for example for factories which receive
the class and return an instance, you can use the type
(or Type
< 3.9) generic. If the class is
defined in the same file, use lazy annotations or put the class name in quotes.
from __future__ import annotations
class MyClass:
@classmethod
def from_dict(cls: type[MyClass], data: dict[str, str]) -> MyClass: # or 'MyClass'
return cls(**data)
def my_factory(factory_class: type[MyClass]) -> MyClass:
return factory_class.from_dict({'test': 'hello'})
Typing Async methods
Although async methods and functions technically return coroutines, when using annotations, you specify the actual return value of the function as if it were not async.
from typing import Awaitable
async def get_user_ids() -> list[int]:
return [1, 2, 3, 4, 5]
user_ids: list[int] = await get_user_ids()
user_ids_coroutine: Awaitable[list[int]] = get_user_ids()
Type Stubs
Type stubs are stub files (.pyi) that contain
the type definitions for libraries that are not themselves typed directly. They can be installed
through pip for type checking. They are less and less required as more and more libraries are using
types, but were used a lot when both Python 2 & 3 support was required. ex: - types-requests
-
types-pytz
. The main repo that collects all of these is called
typeshed, but stub files can also be included in projects,
even if that is no longer needed as the code can be directly typed.
Lazy Annotations / ìf TYPE_CHECKING:
You can import annotations
from __future__
to delay the evaluation of annotations. Thus, they
are only evaluated by type checkers or if you specifically inspect that type in your code.
If you have annotations it’s always good to use this import since:
- It prevents wasted processing of annotations at runtime
- It allows you to use classes that are defined in the same file, or currently being defined, as type annotations
- It allows you to use features and syntax of later versions of Python if your type checker is run with them. E.g. You can use the union operator from Python 3.10 in code that runs in 3.9 at runtime so long as your type checking is done with 3.10 or later and you use the lazy evaluation.
You can also use if TYPE_CHECKING:
to encompass code that is only necessary for type checking or
that you don’t want to run at runtime.
This allows you to, for example:
- Create
Self
,NotRequired
or other types/options for typing (if these aren’t supported in the current version) - Do some unfortunate workaround for typing in older language versions
- Import things from typing that don’t exist in older versions of Python like
StringLiteral
- Import things that could create problems if imported at runtime (e.g. avoid circular import errors).
In general, use TYPE_CHECKING
only if necessary. Typing-only imports should be treated as any
other import.
Example:
# Copyright ...
from __future__ import annotations
import threading
from collections.abc import Callable, Collection
from typing import TYPE_CHECKING, TypedDict
from wazo_auth_client.client import AuthClient # only needed for type checking
if TYPE_CHECKING:
from typing import NotRequired # Only exists in 3.11+
Callback = Callable[[Collection[str]], None]
class CallbackDict(TypedDict):
method: Callback
details: bool
extra: NotRequired[str]
class TokenRenewer:
def __int__(self, auth_client: AuthClient) -> None:
self._auth_client = auth_client
self._callback_lock = threading.Lock()
self._callbacks: list[CallbackDict] = []
def subscribe_to_token_change(self, callback: Callback) -> None:
with self._callback_lock:
self._callbacks.append({'method': callback, 'details': False})
Typing from the future
If you would like to use typing feature have been added to the language, but do not exist in the version you are using, you have two options:
-
You can use
ìf TYPE_CHECKING
and Lazy Annotations and run your linting with a more modern version of Python to run as mentioned above. -
You can use Typing Extensions which is a library that backports certain types to older versions of Python. So you can simply import them from their if they are available. However, if you use any imports from that library outside an
if TYPE_CHECKING
block, you must add it as a runtime dependency or else you will get anImportError
at runtime. Wazo includes a backport of version 4.4.0# from typing import Self # Only exists in Python 3.11+ from typing_extensions import Self # use the backport class MyClass: @classmethod def setup(cls) -> Self: return cls()
Parametric typing & Generics
Generics are types that can be passed
parameters to ensure a useful definition. For example, it’s good to know your method receives a
list, but it’s not super useful if you don’t know what said list contains. So you can pass options
to the list
type to specify its contents (e.g. list[int | str]
). You can create custom ones, but
a lot of builtin ones exist e.g.:
Generic built-in collections
All the basic types that contain other types can be passed parameters to specify their contents (ie.
list
, tuple
, dict
, set
).
import string
alphabet: list[str] = list(string.ascii_lowercase)
two_numbers: tuple[int, int] = (1, 2)
variable_numbers: tuple[int, ...] = (1, 2, 3, 4)
no_doubles: set[str] = {'a', 'b', 'a'}
scores: dict[str, float] = {'bob': 99, 'fred': 100}
Note: If you type a tuple
it will expect exactly those types in that order. If you want to allow
a variable number of items in a tuple, type the first entry and follow it with an ...
, eg.
tuple[int, ...]
Callables
When passing around methods as callbacks you can use the built-in generic
[Callable](https://docs.python.org/3/library/typing.html#typing.Callable)
. The first option is the
arguments and the second is the return value. For more complex cases see Protocols
from collections.abc import Callable
def my_handler(name: str, timeout: int = 5) -> bool:
with contextlib.suppress(KeyError):
# do something
return True
return False
def process_handler(handler: Callable[[str, int], bool]) -> None:
handler('test')
process_hander(my_handler)
Literal values
To allow only a specific set of values you can use
[Literal](https://docs.python.org/3/library/typing.html#typing.Literal)
. It is useful for when you
expect specific strings like 'on'
or 'off'
. The standard library uses this for read/write modes
for example.
from typing import Literal
the_answer: Literal[42] = 42 # No other int is accepted, but 42
mode: Literal['on', 'off'] = 'off' # Only accept these two strings
def open(filename: str | Path, mode: Literal['rb', 'r']) -> BytesIO | StringIO:
...
TypeVar
[TypeVar](https://docs.python.org/3/library/typing.html#typing.TypeVar)
allows you to reference a
type without knowing it. For example, a generic method that receives a list of items and returns the
first. It doesn't matter what the list contains. Just that you will return the first item if it is
not empty.
from typing import TypeVar
T = TypeVar('T')
def get_first(items: list[T]) -> T | None:
return items[0] if items else None
This allows for mypy and other type checkers to replace T
based on what is passed to the function.
For example if you pass a list of int
it knows that it will return a single int
or None
Protocols and structural typing
Protocols are used to implement essentially “static duck typing” 🦆. Where the actual type is not
important so much as what it implements. Do you need a list
or would anything that is iterable
work?
Builtin types:
-
Iterable
an object that implements__iter__
or__getitem__
-
Sequence
an iterable that defines__len__
,__getitem__
and a few others. Eg.from collections.abc import Sequence def check_len(items: Sequence[int], limit: int) -> bool: return len(items) <= limit
Protocols
Protocols allow you type an object that has a specific signature. e.g. implements a given method or has a given property. You can also use them to handle more complex cases of callables.
from __future__ import annotations
from typing import Protocol
class ComplexCallable(Protocol):
def __call__(self, x: int = ..., /, timeout: int = 12) -> float:
...
Typing Dynamic patterns
Mixins
Mixins can be challenging to type if they have implicit dependency on other interfaces (i.e. expect the inheriting class to implement other methods used by the mixin implementation). Intersection types are not supported yet, but would allow specifying a self-type that combines the mixin’s interfaces.
Intersection types are equivalent to ad-hoc subclasses of the types in the intersection. To type a mixin properly, the implicit dependencies must be made explicit, for example through a separate protocol or ABC defining the required interface, which the mixin can subclass.
Self
with inheritance
Self
was introduced in 3.11, but is equivalent to Self = TypeVar("Self", bound="MyClass")
. This
can be important for class methods that return instances of themselves because simply using the
class name with result in issues if the class is extended. In simple cases you can return the class
itself, but Self
is a safer option.
from typing import Self # Self was added in 3.11
# For the same result on Python < 3.11 you can create a TypeVar bound to the class.
# Self = TypeVar("Self", bound="ParentClass")
# Or you can import from typing_extensions if you have it installed.
class ParentClass:
def return_self(self: Self) -> Self:
return self
class ChildClass(ParentClass):
def return_self(self: Self) -> Self:
# If we had used `-> ParentClass` this would then return the wrong type as it is now `ChildClass`
return super().return_self()
Type Aliases
You can easily create aliases for types by simply assigning them to variables. They can also be
explicitly typed with TypeAlias
, but it changes nothing functionally. This is no different than
manually specifying the type each time, but can be more readable especially when types get
complicated and it can avoid copy/paste errors and ensure the type is always the same.
from collections.abc import Callable
from typing import TypeAlias
HandleFunction = Callable[[FastAGI, DictCursor, list], None]
SetupFunction: TypeAlias = Callable[[DictCursor], None]
Extra specificity with New Types
[NewType](https://mypy.readthedocs.io/en/stable/more_types.html#newtypes)
can be used to add
increased specificity to a type allowing for a distinction between it and other values of the same
base type. For example, if you have a functions that frequently use User IDs and you want to
indicate that this is different from standard integers you can create a new UserID
type and the
type checker will indicate an error if it instead receives simply an int
.
from typing import NewType
UserId = NewType('UserId', int)
def get_user(user_id: UserId) -> User:
return User(user_id)
get_user(42) # invalid
get_user(UserId(42)) # valid
This also enables quickly creating a thin abstraction over the underlying type, which helps reduce future refactoring. The actual implementation type can later be changed(for example to a custom class implementation) without breaking typing consistency everywhere and having to rewrite all interfaces.
Integrating into workflow
Config
You can configure mypy
with the rest of the tool in the pyproject.toml
file. Here is an example
config entry for a fully typed or new project.
[tool.mypy]
python_version = "3.10"
show_error_codes = true
check_untyped_defs = true
warn_unused_configs = true
disallow_untyped_calls = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
warn_unused_ignores = true
strict_equality = true
strict_concatenate = true
no_warn_no_return = true
[[tool.mypy.overrides]]
module = [
"*.tests.*",
"integration_tests.suite.*",
]
disallow_untyped_defs = false
disallow_untyped_calls = false
If you want to slowly add typing to an existing project you can start with a more relaxed config that will allow for some untyped or partially untyped functions.
[tool.mypy]
python_version = "3.10"
show_error_codes = true
check_untyped_defs = true
warn_unused_configs = true
ignore_missing_imports = true
If you have any settings that are unique to just one particular repo, you should add a comment separating them from the rest and indicate why the option was added in case it is no longer required in future.
Pre-commit
To Install and run
pip install pre-commit
# To run automatically as hook
pre-commit install
# To run manually
pre-commit run --all-files
Example config:
# See https://pre-commit.com for more information
repos:
- repo: https://github.com/PyCQA/flake8
rev: '6.0.0'
hooks:
- id: flake8
# Required to make flake8 read from pyproject.toml for now :(
additional_dependencies: ['flake8-pyproject']
- repo: https://github.com/psf/black
rev: 22.12.0
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.991
hooks:
- id: mypy
language_version: '3.10'
additional_dependencies:
# Only include the stubs required for your project
- 'types-flask'
- 'types-psycopg2'
- 'types-pytz'
- 'types-pyyaml'
- 'types-requests'
- 'types-setuptools'
- 'types-werkzeug'
Tox
We can also use tox
to run mypy with our other linters via pre-commit
. This allows for a unified
workflow, and it is run in our CI.
[testenv:linters]
basepython = python3.10
skip_install = true
deps = pre-commit
commands = pre-commit run --all-files
Editor integrations
PyCharm
PyCharm supports Type Hinting and code completion out of the box.
VSCode
VSCode supports type checking via the
Pylance extension
and can be enabled by adding this option to your settings.json
file.
{"python.analysis.typeCheckingMode": "basic"} # or "strict"
Also, the mypy plugin directly supports mypy as the type checker.
Jedi
VSCode, Vim, Emacs, Kate and many others can also make use of them via Jedi.
Special cases
Unknown type
If it is not possible to know what type a function can return, or if it can accept any value, you
can use TypeVar
to pass through the value or use Any
which matches any type. Try to avoid Any
as much as possible though and instead use TypeVar or Generics.
Ignoring types
In rare cases it might be required to tell type checkers to ignore a line of code.
test: int # type: ignore
# https://mypy.readthedocs.io/en/stable/error_codes.html#error-codes
test: int = None # type: ignore[error-code]
For example, sometimes variables are set to None
, but are initialized before anything is run, and
thus are never really None
. In these cases, you can ignore the type for assignment only.
from configparser import RawConfigParser
MY_CONFIG: RawConfigParser = None # type: ignore[assignment]
def is_debug() -> bool:
return MY_CONFIG['DEBUG'] is True # mypy knows this is not None
def init() -> None:
global MY_CONFIG
with open('config.ini') as f:
config = RawConfigParser()
config.read_file(f)
MY_CONFIG = config