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:

  1. Obtain the type of a variable:

    type(object)
    
  2. 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__})
    
  3. Defining metaclasses (beyond the scope of this section)

  4. 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 the typing 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 runtime

    import 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

PEPTitlePython Release
526Syntax for Variable Annotations3.6
563Postponed Evaluation of Annotations3.7
585Type Hinting Generics In Standard Collections3.9
604Allow writing union types as X | Y3.10
695Type Parameter Syntax3.12

  1. 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 as cast, may have a minimal runtime impact.

  2. This rule generally applies, with exceptions such as when using typing.Protocol or a children of enum.Enum.

  3. Iterable is an Abstract Base Class (ABC). The typing library provides an alias for this and other protocol-based objects specifically for typing 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.