Python Type Annotations Full Guide
This notebook explores how and when to use Python’s type annotations to enhance your code.
Note: Unless otherwise specified, any code written in this notebook is written in the Python 3.10 version of the Python programming language. Lines of code that are feature-specific to versions 3.9 and 3.10 will be annotated accordingly.
IMPORTANT: Type annotations only need to be done on the first occurrence of a variable’s name in a scope.
Table of Contents
- Introduction
- Basic Variable Type Annotations
- Collections
- Function Signatures and Callables
- Class Type Annotations
- Iterators and Generators
- Advanced Python Data Types
- Advanced Python Type Annotations
Introduction
Python Type Annotations, also known as type signatures or “type hints”, are a way to indicate the intended data types associated with variable names. In addition to writing more readable, understandable, and maintainable code, type annotations can also be used by static type checkers like mypy to verify type consistency and to catch programming errors before they are found the traditional way, at runtime. It should be noted that type annotations create no new logic at runtime and thus are designed to generate nearly zero runtime overhead, so there’s no risk of decreased performance.
The typing module is the core Python module to handle advanced type annotations. Introduced in Python 3.5, this module adds extra functionality on top of the built-in type annotations to account for more specific type circumstances such as pre-python 3.9 structural subtyping, pre-python 3.10 union types, callables, generics, and others.
Basic Variable Type Annotations
General form (with or without assigned value):
<variable_name>: <typename> = initial_value
<unassigned_var>: <typename2> # assign the var later but declare type now
Here are some examples of basic annotated types:
# Basic types
count: int = 0
is_required: bool
price: float = 9.99
manager_name: str = "Bob"
Dynamically (Union) Typed Variables
If the dynamic typing is needed, use Union
(pre-Python 3.10, imported from typing
) or the pipe operator, |
(Python 3.10+):
## Dynamic (Union) types
from typing import Union # not required Python 3.10+
dynamic: Union[int, str] = "I can be either int or str" # pre-Python 3.10
dynamic_new: int | str = 100 # new alt. syntax in Python 3.10+
Since for Union types, you have no way of knowing/ensuring exactly what type a variable is at compile-time, you must use either assert isinstance(...)
or if isinstance(...)
statements to fulfill the runtime type-checking and type-safety that type checkers can’t verify. See examples below.
How To Use Union-typed Variables
import math
def add_five(val: int | str) -> int | str:
result: int | str
if isinstance(val, int):
result = val + 5
elif isinstance(val, str):
result = int(val) + 5
else: # else not required if type checking, but it's a good practice
raise TypeError(f"Unexpected type {type(val)} for val, must be int or str.")
return result
def add_five_get_first_dig(val: int) -> int:
result: int | str = add_five(val)
# even though we passed add_five an int, we can't guarantee it returned an int due to its type
assert isinstance(result, int) # assert this value should only ever be an int
return result // 10 ** (math.ceil(math.log10(result)) - 1)
Optional Variables
Oftentimes, values need the option to end up in a “null” or empty state. These are known as optional values, which use the type format Optional[T]
where T
is the possible non-None type. Alternatively, new to Python 3.10, the new T | None
syntax may be used, as seen below.
from typing import Optional # not required Python 3.10+
floaty_or_not: Optional[float] = None # pre-Python 3.10
floaty_or_not_new: float | None = None # new alt. syntax in Python 3.10+
For the same reason as union types, optional types should be only used after its exact type has been resolved at runtime. As a best practice, this means utilizing Python’s is
operator instead of the ==
operator to check for identity instead of equality. See example below.
See PEP 526 - Syntax for Variable Type Annotations for more info.
Collections
When making type annotations for a collection, it is important to also annotate the type of data that is stored within that collection. While collections should almost always be typed to their “deepest” known sub-type, there’s a point where type annotations lose their elegance and instead may transform into monstrous nested strings of death. In such cases, Type Aliases may be used to reduce clutter (more on that later).
With your collections, don’t do this:
poorly_typed_list: list = ["the", "WRONG", "way", "to", "type"] # BAD
Instead, do this:
pet_names: list[str] = ["Skippy", "Skittles", "Stewie"] # list of strings
triple: tuple[int, int, str] = (1, 2, "skip-a-few") # (int, int, str) triple
# undefined/varying length tuples
# may be later re-assigned to a different-sized tuple of the same type
amounts: tuple[int, ...] = (70, 80, 96, 110, 250)
stuff: tuple[float | str, ...] = ("one", 0.5, 0.25, 0.125, "one-sixteenth")
data: set[int] = {1, 2, 10, 200, 1000} # set of integers
fruit_probs: dict[str, float] = { # dict mapping strings to floats
"apple": 0.5,
"orange": 0.3,
"banana": 0.2,
}
In the case of JSON files and other cases where there are unknown types from a function call, annotate as far as is known about the result as possible (ex. at the least, we know json.load
will return a dict mapping str to objects):
from typing import Any
import json
...
config: dict[str, Any] = json.load(file)
# or alternatively,
config: dict[str, object] = json.load(file) # no import needed, but requires assertions
...
Note: For pre-Python 3.9 code, built-in collection types’ annotations are imported from the typing module as
their uppercase variants (i.e. List[int]
)
Nested Collections
# dict mapping strings to tuples of int and int
restaurants: dict[str, tuple[int, int]] = {
"McDonalds": (2, 2),
"Taco Bell": (4, 6),
"Applebee's": (11, 11),
}
# if it gets crazy, create a TypeAlias or a NewType (more on that later):
from typing import TypeAlias # Available Python 3.10+
RestaurantsType: TypeAlias = dict[str, tuple[int, int]] # Note: no "TypeAlias" annotation pre-Py3.10
restaurants: RestaurantsType = {
... # etc. etc. etc.
}
- See TypeAliases for more info on TypeAliases.
- See NewTypes for more info on NewTypes.
Tuple Unpacking
# annotate the variables first, then perform the unpacking
mcds_x: int
mcds_y: int
mcds_x, mcds_y = restaurants["McDonalds"]
Note: This is the only real way to do tuple unpacking right now (see PEP 526). Hopefully in a future release they devise a more elegant method.
See PEP 526 - Syntax for Variable Type Annotations for more info on variable type annotations.
Function Signatures and Callables
Functions’ arguments are all typed normally, and the return type is typed with an arrow (->
) followed by the return type followed by the colon terminating the function signature. Here are some examples.
Simple function:
def count_to(highest_num: int) -> None: # doesn't return anything, so return is None
for i in range(1, highest_num+1):
print(i) # prints 1, 2, 3, ..., highest_num
Function with default values:
def greeting(name: str, *, excited: bool = False) -> str: # * means following args are keyword-only
message = f"Hello, {name}"
if excited:
message += "!!!"
return message
# function usage:
bobs_greeting: str = greeting("Bob") # Bob is the name, and he's not excited
jacks_greeting: str = greeting("Jack", excited=True) # Jack, however, is excited
Slightly more complex function:
def my_func(val1: int, val2: float, val3: str = "nice") -> float:
return (val1 + val2) / 69 if val3 == "nice" else (val1 + val2)
result: float = my_func(1, 2.0)
Functions with *args
(variable-length positional arguments) or **kwargs
(variable-length keyword arguments) are typed a little differently than usual in that the collection that stores them does not need annotation. Here’s a simple example from the mypy docs:
def stars(*args: int, **kwargs: float) -> None:
# 'args' has type 'tuple[int, ...]' (a tuple of ints)
# 'kwargs' has type 'dict[str, float]' (a dict of strs to floats)
for arg in args:
print(arg)
for key, value in kwargs.items():
print(key, value)
Functions designed to never return look like this:
from typing import NoReturn
def just_raise() -> NoReturn:
raise RuntimeError("This function should never return")
See Function Signatures from mypy docs for more info.
Callables
Callables are special types of objects that can be called. The type annotation is written as Callable[[P], R]
where P
is a comma-separated list of types corresponding to the types of the input parameters of the callable, in order, and R
is the return type.
Here are some examples of callables in practice:
from typing import Callable
this_func: Callable[[int, float, str], float] = my_func # silly use-case, but gets the point across
cube_root: Callable[[int], float] = lambda x: x ** (1 / 3) # lambda functions are callables
Note: Callables, when used for Decorators, need a way to specify generic parameters, so use ParamSpec
from the typing
module in the event that’s necessary.
Class Type Annotations
Classes are typed as you would expect although there are some nuances that are handled more explicitly. For instance, class variables must be explicitly typed as ClassVar[T]
where T
is the type of the class variable.
from typing import ClassVar
class ThisObj:
class_var: ClassVar[str] = "This is a class variable" # class variables typed with ClassVar
def __init__(self, var1: str) -> None: # __init__ returns None
self.instance_var1: str = var1 # instance variables (attributes) are typed as expected
def __len__(self) -> int: # never type annotate self
return len(self.instance_var1)
def copy(self) -> ThisObj: # return type is a class
return ThisObj(self.instance_var1)
# User-defined types are still typed as usual
my_this_obj: ThisObj = ThisObj("This is my type of type")
Note: the method return-typed with the class name is a feature added in Python 3.10. Pre-Python 3.10 code can use this feature as well if the line from __future__ import annotations
is written at the top of the file to enable it.
Inheritance
In cases where subtypes of a class are used, the subtype must be annotated with the supertype if the intention is to re-assign the variable between the subtypes of the supertype.
class NewObj1(ThisObj): ...
class NewObj2(ThisObj): ...
Don’t do this:
obj1: NewObj1 = NewObj1("This is a type of object") # OK on its own
obj2: NewObj2 = NewObj2("This is another type of object") # OK on its own
obj2 = obj1 # BAD: this will fail the type checker :(
Instead, do this:
new_obj1: ThisObj = NewObj1("This is a new type of object") # typed with supertype
new_obj2: ThisObj = NewObj2("This is another new type of object") # typed with supertype
new_obj1 = new_obj2 # OK: satisfies "is a" relationship (inheritance), passes type checker :)
See also:
Iterators and Generators
Iterators and generators are objects that implement __next__
or are functions that include the keyword yield
in the body.
Iterators
Iterators are classes in python that implement __iter__
and __next__
. These are usually iterated over with for loops, but you can use them in other ways such as casting them directly to a sequence or even using the built-in next
function to iterate manually. Iterator types are annotated as Iterator[T]
where T
is the type(s) of the items yielded.
Here is an example of an iterator that counts up in triplets until the max_val passed.
from typing import Iterator
class Tripler:
def __init__(self, max_val: int) -> None:
self.curr = 0
self.max = max_val
def __iter__(self) -> Iterator[int]: # this is the iterator of integers
return self
def __next__(self) -> int:
self.curr += 3
if self.curr > self.max:
raise StopIteration
return self.curr
tripling: Iterator[int] = Tripler(30)
for i in tripling:
print(i) # prints 3, 6, 9, 12, 15, 18, 21, 24, 27, 30
Generators
Generators are like iterators in that they continuously “return” a next value, but they differ in that they can return objects instead of just numerical values, and they can be written as functions. Generator return types are annotated as Generator[Y, S, R]
where Y
is the type of the yielded values, S
is the type of the values expected to be sent to the generator (if applicable), and R
is the type of the return value of the generator (if applicable). Not all generators have send or return values, so these may be replaced with None
if not applicable.
In the case below, the generator function only returns integers, so we can type it as an iterator of integers for simplicity’s sake. However, if desired, it can also use the traditional method of generator typing.
from typing import Iterator
def squares(max_val: int) -> Iterator[int]: # alt. Generator[int, None, None]
i = 0
while i < max_val:
yield i**2
i += 1
This next example cannot be typed as an iterator because it returns objects, so it’s a generator.
import string
from typing import Generator
# Generator type format: Generator[YieldType, SendType, ReturnType]
def abcs(max_letter: str = "z") -> Generator[str, None, None]:
for i in range(ord(max_letter) - ord("a") + 1):
yield string.ascii_lowercase[i]
Here we have a generator with yield, send, and return support. This example’s parameter max_num
starts at infinity, and can be passed as either a an int or a float.
from typing import Generator
# Generator type format: Generator[YieldType, SendType, ReturnType]
def n_divs(max_num: int | float = float("inf")) -> Generator[float, int, str]:
i: int = 0
while i <= max_num:
reset_val = yield 1 / i # can be sent an int mid-iteration, yields floats
if reset_val:
i = reset_val
elif reset_val == 0:
return "FAIL" # returns strings
else:
i += 1
return "SUCCESS"
Note: the pipe ( | ) operator between types used above is not supported pre-Python3.10 (See Dynamically Typed (Union) Variables).
Advanced Python Data Types
Enums
Enums don’t need to be typed in their construction since their type is inherently being defined. As such, they do need to be annotated when referenced (see example below).
from enum import Enum
class Direction(Enum):
NORTH = 1
EAST = 2
SOUTH = 3
WEST = 4
curr_direction: Direction = Direction.NORTH
NamedTuples
NamedTuples are typed normally and constructed as a class inheriting from typing.NamedTuple
.
from typing import NamedTuple
class Color(NamedTuple):
red: int
green: int
blue: int
purple: Color = Color(red=255, green=0, blue=255)
Note: while there is an alternative (legacy) method of creating namedtuples using collections.namedtuple
, it is not recommended to use this method and is recommended to use the typing.NamedTuple
instead as the former requires you to enter the type name as an argument string and does not support type annotation.
Dataclasses
Dataclasses are also typed as expected.
from dataclasses import dataclass, field
@dataclass
class Employee:
name: str
age: int
salary: float
allergies: list[str] = field(default_factory=list)
my_empl: Employee = Employee(name="John", age=30, salary=100000, allergies=["peanuts"])
Numpy Arrays
Numpy arrays are typed with the PyPI package nptyping (ver. 2.0.0+). This is so that we get explicit shape typing, and an overall cleaner annotation system. Unfortunately, this means that type checkers like mypy can’t actually check the details of the typed numpy array (only whether the variable is or is not an ndarray), so at the moment, it’s almost purely a glorified comment.
Type annotations are formatted as NDArray[S, T]
where S
is the intended shape of the array (see nptyping Shape expressions), and T
is the intended data type of the array (see nptyping dtypes). Additionally, the structure of an array can also be annotated (see nptyping Structure expressions).
Shape Typing
Shapes are represented as strings containing a comma-separated list of integers corresponding to the shape of the ndarray. For example, a 2D array of shape (3, 4) would be represented as "3, 4"
. In addition, shapes can also be more dynamically typed with wildcards (*) in place of single dimension numbers to represent any length for that dimension, and they can also be labeled and named. A full detailing of Shape expressions can be found here.
Data Type Typing
Data types are imported from nptyping
explicitly. Some commonly used types that can be imported are Int
, UInt8
, Float
, Bool
, String
, and Any
. A full list of available dtypes can be found here
Here are some examples of type annotated numpy arrays.
import numpy as np
from nptyping import NDArray, Shape, Int, UInt8, Int16, Int32, String, Float32
from typing import Any
literally_anything: NDArray[Any, Any] = ... # any shape, any type
image: NDArray[Shape["1080, 1920, 3"], UInt8] = ... # 3-channel 8-bit image of 1920x1080 pixels
two_dimensional_arr: NDArray[Shape["*, *"], Int] = ... # any length but only 2 dimensions, Int32
square_arr: NDArray[Shape["T, T"], Int] = ... # repeated dimension sizes (T can anything)
n_dimensions_of_4: NDArray[Shape["4, ..."], Int] = ... # any n number of 4-long dimensions, Int32
nums: NDArray[Shape["5"], Int32] = np.arange(5) # shape=(5,) (Note: Int <=> Int32)
# any length 1D arr of strings
phrases: NDArray[Shape["*"], String] = np.array(["hello, world", "goodbye, world"])
phrases = np.append(phrases, "there is no world") # OK
points: NDArray[Shape["*, 2"], Int] = np.array([[1, 2], [3, 4]]) # 2 rows, any number of columns
better_pts: NDArray[Shape["*, [x, y]"], Int] = ... # type same as above, but w/ dimension breakdown
unlabeled_coords: NDArray[Shape["5, 2"], Float32] # 5x2 array of coordinates
labeled_coords: NDArray[Shape["5 coordinates, [x, y] wgs84"], Float32] = ... # 5 wgs84 coordinates
See Nptyping Documentation for more info on how to use nptyping.
Note: While numpy does have its own numpy.typing
library, for a variety of reasons, we no longer use this library and thus do not recommend it.
Other Advanced Types
Here are a list of other advanced types that are not covered in the above sections with links to their type annotation documentation:
- attrs
- TypedDicts
- Awaitables & Asynchronous Iterators/Generators
- Protocols
- Final (Uninheritable) Attributes
- metaclasses
- Literals
Advanced Python Type Annotations
Type Aliases
A Type Alias is simply a synonym for a type, and is used to make the code more readable. To create one, simply assign a type annotation to a variable name. Beginning in Python 3.10, this assigned variable can be typed with typing.TypeAlias
. Here is an example.
from typing import TypeAlias
ChestOfThings: TypeAlias = dict[str, tuple[list[int], str, float]] # Py3.10+ syntax
ChestOfThingsOld = dict[str, tuple[list[int], str, float]] # pre-Py3.10, (no TypeAlias annotation)
# ChestOfThings = Dict[str, Tuple[List[int], str, float]] # pre-Py3.9, types imported from typing
my_chest: ChestOfThings = {"key": ([1, 2, 3], "value", 1.0)}
New Types
New Types are a way to definte types that wrap existing types in Python. What this means is that you can define a new type that is a subtype of an existing type with almost no class/inheritance overhead, and then use that new type in place of the existing type.
from typing import NewType
PhoneNumber = NewType("PhoneNumber", int)
my_number = PhoneNumber(5733414111)
Email = NewType("Email", str)
my_email = Email("multirotor@mst.edu")
See Python typing
- NewType for more info.
Type Variables
Type Variables are a way to define a type that can be used in place of a type (with or without constraints on what those types may be), but is not a type. This is useful for defining generic types.
Let’s take a look at what the class signature for typing.TypeVar
.
TypeVar(
name, # The name of the type variable
*constraints, # optional specific constraints, type has to be one of these
bound: __class__ = None, # upper bound (only this and subclasses of this)
covariant: bool = False, # allow usage of more derived subtypes?
contravariant: bool = False, # allow usage of less derived supertypes?
)
# By convention, append a _co to covariant and a _contra to contravariant type variable names
Here’s an example of TypeVar
in practice:
from typing import TypeVar, Sequence
T = TypeVar("T") # Can be anything
A = TypeVar("A", str, bytes) # Must be str or bytes
# here we can refer to the same arbitrary type 'T' throughout the function
def repeat(x: T, n: int) -> Sequence[T]:
"""Return a list containing n references to x."""
return [x] * n
def longest(x: A, y: A) -> A:
"""Return the longest of two strings or bytes."""
return x if len(x) >= len(y) else y
See also:
Structural Subtyping and Generic Collections (ABC)
Also known as “duck types”, generic collections are a way of defining a type of collection that fits a certain set of operations. These types are all the Abstract Base Classes (ABCs) of common Python collections.
For example, a list
is an generic Sequence
, and a dict
is a generic Mapping
Here are some examples of some common generic collections:
from typing import Iterable, Sequence, Mapping, MutableMapping
# iterables support iteration (__iter__)
def add_two(vals: Iterable[int]) -> list[int]:
return [(val + 2) for val in vals]
# any sequence is an iterable, but not all iterables are sequences
# sequence supports iteration , subscripting (__getitem__), length (__len__), in (__contains__),
# and reversed (__reversed__), and may also support the index and count methods.
def get_mid_val(my_seq: Sequence[float]) -> float:
mid_arg = len(my_seq) // 2
return my_seq[mid_arg]
# a mapping supports subscripting, iteration, length, in, the keys, items, values, and get methods,
# and the equality operators == and != (__eq__ and __ne__ respectively)
def dictify(my_mapping: Mapping[str, bool]) -> dict[str, bool]:
result: dict[str, bool] = {}
for key, val in my_mapping.items():
result[key] = val
return result
# Mutable Mappings are the same as Mappings except they also support subscripting with assignment
# (__setitem__) and del on an item(__delitem__)
def double_values(my_mut: MutableMapping[int, float]) -> MutableMapping[int, float]:
for key, val in my_mut.items():
my_mut[key] = 2 * val
return my_mut
More information and other abstract base classes can be found here.
See also:
User-Defined Generics
Oftentimes when you want to create your own collection, you want to be adapatable as to what types it can take. In this case, we combine TypeVar
and Generic
to create a generic collection.
Here are some simple examples:
from typing import TypeVar, Generic
T = TypeVar("T")
class Jar(Generic[T]):
def __init__(self, content: T) -> None:
self.content: T = content
from typing import TypeVar, Generic
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self.items: list[T] = []
def push(self, item: T) -> None:
self.items.append(item)
def pop(self) -> T:
return self.items.pop()
Note: the usage of T
as a type variable is a convention and can be substituted with any name. Similarly, the convention for user-defined generic mappings or other paired values is K
, V
(usually used as Key, Value)
See also: