Typing
Although Python is a dynamically typed programming language, support for function
annotations has been around since version 3.0, as introduced in
PEP 3107. Extended support, including the typing
library, was added to Python’s standard library in version 3.5 with the use of type
hints (see PEP 484).
Python’s type system is not enforced at all, it is completely1 ignored by the interpreter at runtime. This can be both beneficial and disadvantageous for the Developer Experience.
One advantage is that there is no transpiler requirement, unlike languages such as TypeScript in the JavaScript ecosystem. However, Python still offers richer ergonomics and functionality than comment-based types, like the ones used in Lua, with LuaLS type annotations.
Type hinting is built into the Python's grammar. Additionally, type hint definitions
can be accessed through the object's __annotations__
attribute. Nonetheless, typing
lacks of strict rules unless continuous integration tooling is implemented in the code’s
lifecycle, e.g. using type checkers such as mypy
.
The recommended resource for learning about Python’s type hints is the documentation on
the typing
library. It covers the
fundamentals of implementing type annotations to a codebase, including clever examples.
Nevertheless, the following sections will focus on commonly used functionalities and
types that may be ambiguous or insufficiently covered in the official documentation.
Annotations
Default behavior
Annotating a callable parameter with a type, as in:
def func(some_str: str) -> None: ...
indicates that some_str
must be an instance of str or a subclass of it2,
not the type per se. Type annotations assume values are instances of the class or any
of their children rather than the class itself.
Therefore, the following are completely valid:
class MyInt(int): ...
def func(int_instance: int) -> None: ...
func(int())
func(MyInt())
$ uv run mypy main.py
Success: no issues found in 1 source file
To pass a class
as a parameter, the notation requires the use of typing.Type
or
type
. This is because Python’s class syntax is syntactic sugar for creating
types. The same rule applies: subclasses of the specified class are also valid as
function arguments.
import typing as t
class MyInt(int): ...
def func(int_type: t.Type[int]) -> None: ...
func(int)
func(MyInt)
$ uv run mypy main.py
Success: no issues found in 1 source file
Generics
Python does not have generics like those in C++, Java, or Rust, where type-specific
versions of a function are resolved at compile time. Instead, generics in Python exist
purely for type annotation purposes. The language inherently supports writing functions
that operate on multiple types without requiring duplication. However, typing generics
allow developers to dynamically support types based on the object's definition,
narrowing the function’s scope: enabling better static analysis and code reusability.
Both PEP 484 and the
typing
library documentation
provide in-depth guidance on working with generics.
The typing
documentation introduces newer syntax for defining generics in both
callables and classes. Enhancements in Python 3.12 enable the creation of generics using
concise one-liners. Previously, defining generics required explicitly declaring type
variables, binding rules, and variance constraints. This is no longer necessary.
For functions:
def func[T: (int, str)](some_generic: T) -> T:
return some_generic * 2
int_expression = func(1) # inferred as int
str_expression = func("foo") # inferred as str
$ uv run mypy main.py
Success: no issues found in 1 source file
For classes:
from dataclasses import dataclass
@dataclass
class MyClass[T]:
some_attr: T
The documentation explains the implementation of generics under the hood, MyClass
silently inherits from typing.Generic
, in Python 3.11 and earlier, explicit
inheritance was required to use type generics.
An important distinction between class and callable generics, highlighted in PEP 484 but not explicitly mentioned in the typing documentation, is the scoping of type variables. In generic classes, a type variable used in a method or attributes that coincides with a class-level type variable is always bound to the class-level definition. This means generics apply at the class level, rather than being redefined per method or attribute.
import typing as t
T = t.TypeVar("T")
class MyClass(t.Generic[T]):
def __init__(self, x: T) -> None:
super(MyClass, self).__init__()
self.x = x
def instance_func(self, x: T) -> T:
return x
my_instance = MyClass(x=1) # Generic T is now type `int` for the whole instance
my_instance.instance_func("1")
$ uv run mypy main.py
main.py:16: error: Argument 1 to "instance_func" of "MyClass" has incompatible type "str"; expected "int" [arg-type]
Found 1 error in 1 file (checked 1 source file)
For further details, refer to the PEP 484 scoping rules.
Finally, there are some nuances behind the implementation of class generics that can be confusing to the eye. For example:
import typing as t
T = t.TypeVar("T", int, float)
class MyIterable(t.Iterable[T]): ...
Is a compact version of:
import typing as t
T = t.TypeVar("T", int, float)
class MyIterable(t.Iterable, t.Generic[T]): ...
The generic has nothing to the with the Iterable
Abstract Base Class. It is a
convenient syntax to avoid explicit inheritance of typing.Generic
.
Using Python 3.12’s generics syntax and the source import reference3, this can be rewritten as:
from collections.abc import Iterable # same as typing.Iterable
class MyIterable[T: (int, float)](Iterable): ...
Protocol
Trait-like or interface-based programming can be achieved using typing.Protocol
. It
serves as an alternative to abstract base classes without requiring full-fledged
Object-Oriented Programming (OOP) constructs.
import typing as t
class Proto(t.Protocol):
def must_implement(self) -> None:
return None
class InheritsProto(Proto): ... # implements by inheritance
class ImplementsProto:
def must_implement(self) -> None: ...
# These are equivalent
def func(implements_proto: Proto) -> None: ...
def fn[T: (Proto)](implements_proto: T) -> None: ...
func(InheritsProto())
func(ImplementsProto())
$ uv run mypy main.py
Success: no issues found in 1 source file
Note that the method body inside the protocol explicitly returns None
instead of using
an ellipsis (...
). In Python, the idiomatic way to define an empty function body—such
as for an abstract method or a protocol—is by using either the pass keyword or the
ellipsis.
In this case, the must_implement
method includes a return statement, meaning there is a
implementation of the protocol. This allows type checking to pass. However, if the
Proto
class had an empty function body and none of its subclasses implemented the
method, a type-checking error would occur. At least one class in the method resolution
order (__mro__
) must provide a concrete implementation that matches the protocol’s
method signature, including its parameter and return type.
type
: function and soft keyword
type
is both a function and a keyword in Python, serving four distinct purposes:
-
Obtain the type of a variable:
type(object)
-
Dynamically creating types at runtime by providing a name, base classes, and namespace:
def __init__(self, attr: str) -> None: super(MyClass, self).__init__() self.attr = attr return None MyClass = type("MyClass", (object,), {"__init__": __init__})
-
Defining metaclasses (beyond the scope of this section)
-
Optional keyword before a type expression (introduced in Python 3.12):
# These are equivalent type Str = str Str = str
This syntax explicitly marks a variable as a type alias for static type checking. Although type checkers will enforce correctness, there are no runtime exceptions when assigning instances to a type alias. The type declaration takes precedence over a normal assignment, creating an unbound type alias, which can lead to unintended behavior if misused.
Enums
The Python standard library has included support for Enums since version 3.4 with the
enum
library, introduced in PEP 435. While Enums
are not part of the typing
library, their functionality integrates seamlessly with
the typing principles.
Enum classes are not designed to be instantiated, even though this can be done passing one of the member values as an argument. Instead, the recommended way to use Enums is by accessing their members directly like class attributes.
from enum import Enum, auto
class MyEnum(Enum):
FOO = auto() # Assigns value 1
BAR = auto() # Assigns value 2
def func(formatter: MyEnum) -> None: ...
func(MyEnum.FOO)
$ uv run mypy main.py
Success: no issues found in 1 source file
It is important to note that accessing an Enum member does not return its associated
value; it returns the member itself. To access the underlying value, use the .value
attribute, or to get the member’s name as a string literal, use .name
.
Therefore:
from enum import Enum
class Formatter(str, Enum): # since python 3.11, can replace bases with enum.StrEnum
JSON = "json"
LOGFMT = "logfmt"
print(Formatter.JSON, f"{type(Formatter.JSON)=}")
print(Formatter.JSON.name, f"{type(Formatter.JSON.name)=}")
print(Formatter.JSON.value, f"{type(Formatter.JSON.value)=}")
$ uv run main.py
Formatter.JSON type(Formatter.JSON)=<enum 'Formatter'>
JSON type(Formatter.JSON.name)=<class 'str'>
json type(Formatter.JSON.value)=<class 'str'>
But for some use cases, operators with Enum members and values are interchangeable if
the Enum inherits from int
or str
, or their equivalents (IntEnum
, StrEnum
).
from enum import Enum
class TokenStatus(int, Enum):
ACTIVE = 1
EXPIRED = 2
def func(some_int: int) -> int:
if some_int == TokenStatus.ACTIVE:
return some_int
elif some_int == TokenStatus.EXPIRED:
return some_int
else:
return 0
func(1)
func(TokenStatus.EXPIRED)
Even though TokenStatus.EXPIRED
is not an instance of int
, it passes static type
checking
$ uv run mypy main.py
Success: no issues found in 1 source file
However, if func
’s parameter some_int
had a type hint of TokenStatus
,
a static type checking error would occur. Regardless, the function behavior remains the
same. This can lead to unexpected behavior. For that reason, inheriting exclusively from
Enum
can be beneficial, at the expense of losing convenience methods on the members.
String Enums, in particular, can often be better represented using typing.Literal
This allows users to type the value directly as a raw string without needing to import
library-specific Enums, while also improving awareness of possible values.
import typing as t
def func(some_literal: t.Literal["aio", "threading"]) -> None: ...
Casting types at runtime
A function may return typing.Any
or an unknown type that cannot be inferred from its
signature. This is common in older or poorly documented third-party libraries. In such
cases, developers can define type annotations to improve the development experience, at
the risk of encountering unexpected behavior if objects do not match the expected
signature.
Introduced in Python 3.6 with PEP 526, the following syntax is permitted:
import typing as t
def func() -> t.Any:
return str()
my_str: str = func()
In this example, it is safe to assume that the function returns a str
instance.
However, in real-world scenarios, determining the actual return type may be more
complex. If previous application logic suggests a predictable set of possible return types,
it can be useful, albeit unsafe to provide an explicit type hint.
It is worth noting that the previous example does not work for instance attributes:
import typing as t
class MyClass:
def __init__(self, attr: t.Any):
self.attr = attr
def func(some_instance: MyClass):
some_instance.attr = str()
return some_instance
my_instance = MyClass(1)
my_mod_instance = func(my_instance)
# can safely assume that attr is of type str
my_mod_instance.attr: str
$ uv run mypy main.py
main.py:17: error: Type cannot be declared in assignment to non-self attribute [misc]
Found 1 error in 1 file (checked 1 source file)
To address this, the typing
library provides the cast
function. Replace the last
statement with:
my_mod_instance.attr = t.cast(str, my_mod_instance)
The cast
function allows explicitly specifying a type with minimal runtime behavior,
ensuring type checkers recognize the expected type.
Coroutines
Python coroutines (functions marked with the async
keyword) are objects that silently
implement the collections.abc.Coroutine
protocol. Using the async
keyword at the
beginning of a function statement is syntactic sugar for inheriting from the mentioned
type and implementing the __await__
, send
, and throw
methods.
Therefore, there is no typing.AsyncCallable
type built into the typing
library;
instead, to type-annotate an async function, the return type of a callable has to be
wrapped inside collections.abc.Awaitable,
which has a generic parameter
representing the function’s return type. Or it can be annotated as a
collections.abc.Coroutine
, which would be more accurate, but more verbose.
import typing as t
type ACallable[T] = t.Callable[..., t.Coroutine[t.Any, t.Any, T]]
The implementation of asynchronous functionality into the language with the usage of
generators, results in async def
functions being callable objects that return a
Coroutine
with three
generic parameters.
The first two parameters can be safely omitted for these functions when considering type
annotations. In fact, they cannot be set when using the async def
syntax, as there is
no opportunity to modify the YieldType
, nor the SendType
of the object. Attempting
to do so would result in creating an AsyncGenerator
.
However, the last parameter represents the return type of the Coroutine when awaited, making it ideal for providing developers with precise type hints.
py.typed
and .pyi
extension
There are two specific Python-oriented extensions for typing purposes. py.typed
is an
explicit marker for libraries that include types, allowing type checkers like mypy
to
effectively look up type definitions for third-party sources. Advances in developing
tools like Integrated Development Environments (IDEs) with the use of Language Server
Protocols (LSP) have made this less necessary. Yet, it remains a good practice to
include it on library code.
On the other hand, files ending with .pyi
serve as stubs in the programming language
— a stub being a file that contains only type information, with no runtime code. It
simply reveals the interface of objects so developers can use them safely. With type
annotations now built into the language grammar, this may seem unnecessary — similar to
TypeScript type definitions with .d.ts
extension. However, .pyi
files are extremely
useful for codebases written before type hints became standard, and more importantly,
for Python interfaces where the code itself is not written in Python. Such as Python
global functions or any third-party software built with the C API or
PyO3
.
Assume some_package
is a third-party library that cannot be modified directly. The
structure is as follows:
.
├── main.py
└── some_package
├── module.py
└── module.pyi
In this setup, the file module.py
defines the following function:
def func(a, b):
return a + b
Meanwhile, the interface file module.pyi
provides:
def func(a: int, b: int) -> int: ...
Language Server Protocols and type checkers will examine the defined types and use them for features such as code completion, inline error detection, and improved code navigation.
Thus, if main.py
calls func
like:
from some_package.module import func
func("1", "2")
mypy
will catch the undesired usage of the callable
$ uv run mypy main.py
main.py:3: error: Argument 1 to "func" has incompatible type "str"; expected "int" [arg-type]
main.py:3: error: Argument 2 to "func" has incompatible type "str"; expected "int" [arg-type]
Found 2 errors in 1 file (checked 1 source file)
Type-Driven Behavior
Runtime access
The usage of type annotations has become an important element of Python development. Type hints/annotations have evolved into something greater than what the name suggests. There is now a significant portion of libraries that rely on this aspect of Python’s syntax to govern the runtime behavior of application.
To examine the type hints of an object at runtime, the following can be done:
import inspect
def func() -> None: ...
assert inspect.signature(func).return_annotation is None
This opens up the opportunity to create a lot of hidden magic using a decorator, for example.
Python’s standard library dataclass
invites the user to declare classes in a
type-hinted manner. It uses typing.ClassVar
to modify the default behavior of types
being instance attributes. Meanwhile, Python libraries like
Pydantic take type hints to the next level, making
type annotations the absolute source of truth for object instantiation, providing
validation and safety at runtime. Not to mention projects like
FastAPI, which define the complete HTTP structure of an
endpoint with a callable with type-annotated parameters by looking at the function
signature.
Modifiers
-
Strings: Forward references, detailed in PEP 484 occur when an object that has not been defined is used as a type annotation. For that reason, type hints can be expressed as string literals.
from dataclasses import dataclass @dataclass class Tree: left: "Tree" right: "Tree"
The benefit of this is that type checkers will pick the type during static type checking, and IDEs will too. However, this can cause unpredictable behavior for type-driven libraries. Since the following will result in an error:
import inspect # annotation returns string literal: "Tree" assert Tree is inspect.signature(Tree).parameters["left"].annotation # annotation returns string literal: "Tree"
Traceback (most recent call last): File "/mle/code/python/standards/typing/20/main.py", line 13, in <module> assert Tree is inspect.signature(Tree).parameters["left"].annotation ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AssertionError
-
typing.TYPE_CHECKING
: the source code of thetyping
library describes this constant as:# Constant that's True when type checking, but False here.
Combined with using string literals for type annotations, this comes in handy to prevent circular imports, mainly used with a condition. if
typing.TYPE_CHECKING
, import some type using double quotes in source code, since it will not be imported at runtimeimport typing as t if t.TYPE_CHECKING: from some_module.that.causes import CircularImport def func(some_type: "CircularImport") -> None: ...
Enables type checking during development: removes the source signature at runtime, which can conflict if a function expects to resolve the type based on the hint. In comparison with writing a string literal, functions likes
typing.get_type_hints
that search for the types of an object in different higher scopes of the program may raise an expection. -
from __future__ import annotations
: Adding this import in a Python file sets a configuration for the interpreter at runtime. It modifies all type hints to be in the form of string literals, avoiding forward references, exceptions raised by type hints not defined at runtime and more... This was introduced into the language on version 3.7, with details in PEP 563. There was some discussion about making this the default in Python 3.10, but it was postponed and reconsidered in PEP 649, due to Python that rely on the current behavior—where type hints are eagerly evaluated at the time the annotated object is bound
Evolution of Typing Notation
The typing
library has seen continuous extensions since its release. When modern
typing features are needed but the project requires an older Python version, the
typing_extensions
library offers
compatibility with minimal overhead, adding only a single dependency with no additional
requirements.
However, certain notation and syntax changes lack forward compatibility. For example, starting from Python 3.9, standard collection types can be parametrized using square bracket notation. In type checking, the following are equivalent:
import typing as t
def func(some_dict: t.Dict[str, str]) -> None: ...
def func(some_dict: dict[str, str]) -> None: ...
The choice between these notations depends on the context. When developing library code for a broad user base, justifying an upgrade solely for typing improvements may not always be compelling. It is the developer’s responsibility to decide whether to prioritize compatibility over a cleaner syntax or adhere to the more verbose but widely supported options.
Relevant Changes in Typing
PEP | Title | Python Release |
---|---|---|
526 | Syntax for Variable Annotations | 3.6 |
563 | Postponed Evaluation of Annotations | 3.7 |
585 | Type Hinting Generics In Standard Collections | 3.9 |
604 | Allow writing union types as X | Y | 3.10 |
695 | Type Parameter Syntax | 3.12 |
-
Type hints are almost entirely ignored at runtime. There are no automatic runtime validations: no implicit
isinstance
checks are performed on function calls. However, imports must still be resolved, and certain functions, such ascast
, may have a minimal runtime impact. ↩ -
This rule generally applies, with exceptions such as when using
typing.Protocol
or a children ofenum.Enum
. ↩ -
Iterable
is an Abstract Base Class (ABC). Thetyping
library provides an alias for this and other protocol-based objects specifically fortyping
purposes. Inheriting from an ABC has runtime implications beyond its "optional" role in type annotations, in this case, it mandates the implemention of the__iter__
method. ↩