All programming languages have some kind of type system that formalizes which categories of objects it can work with and how those categories are treated. For ex., a type system can define a numerical type with 12 as one example of an object of that type.

Dynamic typing

Python is a dynamically typed language. This means that the interpreter does type checking only as code runs, and that the type of variable is allowed to change over its lifetime.
ex.

if False:
    print(1 + "two")
else:
    print(1+2)
# prints 2, but does not raise a TypeError because 1 + "two" line never runs so it's never type checked.

Variables can also change types:

apple = "apple"
print(type(apple))
# <class 'str'>
apple = 2
print(type(apple))
# <class 'int'>

type() returns the type of an object.

Static typing

Static type checks are performed without running the program. In most statically typed language, like C, this is done as your program is compiled.
Variables are generally not allowed to change types, but mechanisms for casting a variable to a different type may exist.

int apple = 23;

This line declares that apple is bound to the int type at compile time. The name can never be rebound to another type. Your compiler would raise an error if you try to do that.

Python does have type hints, which make it possible to do static type checking in Python code. Type hints by themselves do NOT cause Python to enforce types. Theyjust suggest it.
ex.

apple: int = 23
apple = "twenty-three" # this raises a warning from your linter, but the program still runs fine

Duck typing

If it walks like a duck and it quacks like a duck, then it must be a duck

Duck typing is a concept related to dynamic typing where the type or the class of an object is less important than the method it defines. Using duck typing you do not check types at all - you check for the presence of a given method or attribute.

As an example, you can call len() on any Python object that defines a .__len__() method.

class Book:
""""This class models a simple book"""
    def __len__(self):
        return 200
my_book = Book()
print(len(my_book)) # 200

Note that the call to len() gives the return value of the __len__() method. In fact, the implementation of len() is essentially equivalent to

def len(object):
    return object.__len__()

In order to call len(object), the only real constraint on object is that it must define a __len__() method. Otherwise, the object can be of any type - str, list, dict, or Book.

Duck typing is somewhwat supported when doing static type checking of Python code, using structural subtyping. More on that later in Protocols.

Using type checking

In terms of style, PEP 8 recommends the following:

  • use normal rules for colons, that is, no space before and one space after a colon, for ex. text: str.
  • use spaces around the = sign when combining an argument annotation with a default value, ex. align: bool = True.
  • use spaces around the -> arrow: def headline(...) -> str.

A full function that is written with static type checking:

def read_time(book: Book, speed: int = 5) -> float:
    """
    Returns the read time (in hours) for a particular Book.
 
    Parameters:
        book: A Book instance.
        speed: An integer representing the user's speed in pages per hour.
 
    Returns:
        A float estimate in hours of how long it may take one to read a book.
    """
    # in an idiomatic code, this will be left un-annotated; see below
    length: int = len(book)
    time: float = length / speed
    return time

Note that PEP 526 notes that annotating locals like this is usually unnecessary, since a type checker can already infer the type from the right hand side. Annotations on locals are mainly useful when the inferred type would be wrong or ambiguous, ex. an empty list data: list[int] = [].

Oh, and for doctrings, refer to the styleguide. They use Args but I prefer Parameters. My choice :)

Annotations

Annotations are a way to associate aribtrary expressions to function argumements and return values.
As an example:

def function(arg: arg_type, optarg: arg_type = default) -> return_type:
    pass

You can check annotations in a special __annotations__ attribute.

print(function.__annotations__)

Variable annotations are also supported, but as we’ve seen earlier, it’s kind of unnecessary in situations where the type is obvious.

Typing module and Collections

Sometimes you would want to specify types for a composite type, for ex.

fruits: list = ["apple", "banana", "orange"]
choice: tuple = (1, 2, 3)
answer: dict = {"related": False, "captured": True}

You can use the typing module to type these:

from typing import Dict, List, Tuple
 
fruits: List[str] = ["apple", "banana", "orange"]
choice: Tuple[int, int, int] = (1, 2, 3)
answer: Dict[str, bool] = {"related": False, "captured": True}

But the idiomatic way is to use builtin collection types which can be subscripted directly - that’s the recommended style. The above example can hence be considered deprecated.
example:

fruits: list[str] = ["apple", "banana", "orange"]
choice: tuple[int, int, str] = (1, 2, "three")
answer: dict[str, bool] = {"related": False, "captured": True}

Note that a tuple is an immutable sequence and typically consists of a fixed number of possibly different typed elements. For example, you write tuple[type1, type2, ..., typeN] for an N-tuple.
A list on the other hand is a mutable sequence and usualy consists of an unknown number of elements of the same type. So no matter how many elements are there in the list, there is only one type in the annotation: list[type].

In some cases, your functions will expect a sequence, and not really care whether it’s a list or a tuple. In those cases you should use collections.abc.Sequence when annotating the function argument. Using a Sequence or even an Iterable (again from collections.abc) is an example of duck typing yet again - those can be anything that support len() and __getitem__(), independent of the actual type.

Using type aliases

Sometimes you would have functions like

def do_something(
   basket: list[tuple[str, str]] 
) -> tuple[
    list[tuple[str, str]],
    list[tuple[str, str]],
    list[tuple[str, str]],
]:
    """Do something"""
    pass

This is so NOT readble. Use type aliases in these cases:

Basket: list[tuple[str, str]]
Cart: list[basket]
def do_something(basket: Basket) -> tuple[Cart, Cart, Cart]:
    """Do something"""
    pass

NOTE: Any type
Don’t reach for typing.Any just because you’re unsure of a type - it disables checking entirely for that variable. If the actual type is unknown but bounded, prefer object or a Union of plausible types. Note that you don’t even need to use Union - you can just use |.

Idiomatic use cases for collections.abc

For what ABCs are, refer ABCs.
The main idiomatic use case here is function argument annotations for duck typing - accept the loosest interface you actually need. Annotate parameters with the most general ABC that supports the operations your function performs, so callers can pass any compatible type, not just list.

I asked Claude for a helpful table for this module, and it gave me this:

ABCGuaranteesUse when the function only needs
Iterable[T]__iter__to loop over it once
Iterator[T]__next__ (+ __iter__)to pull items one at a time (stateful)
Reversible[T]__reversed__ (extends Iterable)to iterate in reverse, e.g. reversed(x)
Container[T]__contains__in checks
Hashable__hash__to be used as a dict key or set element
Sized__len__len()
Collection[T]Sized + Iterable + Containerlen, iterate, and in
Sequence[T]__getitem__ + __len__, orderedindexing/slicing but no mutation (list, tuple, str all qualify)
MutableSequence[T]Sequence + append/insert/__setitem__it also needs to mutate the sequence
Mapping[K, V]__getitem__, keys, read-onlydict-like lookup without needing to write
MutableMapping[K, V]Mapping + __setitem__/__delitem__it also needs to write to the dict
Set[T] / MutableSet[T]set algebra operationsunion/intersection semantics
Callable[[Args], Ret]__call__a function-like argument
Generator[Y, S, R]send, throw (extends Iterator)a generator specifically, not just any iterator
Awaitable[T]__await__something usable with await
Coroutine[Y, S, R]Awaitable + send/throwspecifically an async def function’s return object
AsyncIterable[T]__aiter__to loop over it with async for
AsyncIterator[T]__anext__ (extends AsyncIterable)to pull items one at a time, asynchronously
AsyncGenerator[Y, S]asend, athrow (extends AsyncIterator)an async generator specifically
Buffer (3.12+)__buffer__objects implementing the buffer protocol (bytes-like)

Ex. a function that only reads and iterates over its input should take Sequence[int], not list[int] - that also accepts tuples, ranges, etc. and is in accordance with duck typing.

Another use case is runtime isinstance()/issubclass() checks. Unlike most typing constructs, collections.abc classes are real ABCs usable at runtime.
ex.

isinstance(x, collections.abc.Mapping) # True for dict, OrderedDict, etc.
isinstance(x, collections.abc.Sequence) # True for list, tuple, str

A secondary use case is subclassing an ABC, ex. collections.abc.Sequence for your own container class gives you free mixin methods. Implement just __getitem__ and __len__ and you get __contains__, __iter__, etc. for free.

Idiomatic use cases for typing

The most common use case you will see here is using Protocol. More on that in Protocols, but to summarise this is the typing conunterpart to collections.abc duck typing. If you want callers to pass “anything with a .read()”, this is how you do it.

I again asked Claude for the most relevant, and most importantly Python 3.12+ stuff and it gave me the below table. Please note that backward comaptibility is not guaranteed here but it still gave me some legacy stuff.

ConstructWhat it doesUse it when
AnyDisables type checking for that value entirely(avoid) — only as an escape hatch when a type is genuinely unknowable
X | YUnion of types (replaces Union[X, Y])a value can be one of several types
X | NoneOptional value (replaces Optional[X])a value can be absent
TypeVar / GenericLegacy way to declare genericsrarely - 3.12+ native syntax (class Box[T]:, def first[T](...)) replaces this for new code
ParamSpecCaptures a callable’s full parameter signaturewriting a decorator that must preserve the wrapped function’s signature
ConcatenatePrepends fixed params to a ParamSpec in a Callable typea decorator adds/removes a leading argument (e.g. injecting self)
TypeVarTuple / UnpackVariadic generics (*Ts in 3.12+ syntax) / typed **kwargsa generic needs an arbitrary number of type params, or you’re typing **kwargs against a TypedDict
LiteralRestricts a value to specific literal constantse.g. mode: Literal["r", "w", "rb", "wb"]
TypedDictDict with a fixed, typed set of keysrepresenting structured dict data (e.g. parsed JSON) without a class
ReadOnly / Required / NotRequiredField-level modifiers inside a TypedDict (3.13+ for ReadOnly)marking specific TypedDict keys as immutable or optional
NamedTupleTyped, immutable tuple with named fieldsa lightweight immutable record - like a frozen dataclass
FinalMarks a name as non-reassignable / non-overridableconstants, or preventing a subclass from overriding a method
ClassVarMarks an attribute as class-level, not instance-levelannotating class variables inside a class body
Self (3.11+)Refers to the enclosing class’s own typemethods that return self or a new instance of the same class (esp. with inheritance)
TypeGuardCustom narrowing function (asymmetric: only narrows on True)legacy narrowing - mostly superseded by TypeIs
TypeIs (3.13+)Custom narrowing function (narrows on both branches)writing an isinstance-like helper you want the checker to trust fully
overloadDeclares multiple call signatures for one functiona function’s return type depends on which arguments/types are passed
runtime_checkableLets a Protocol be used with isinstance()(see Protocols)
castTells the checker “trust me, this is type X” (no runtime effect)you know a type is narrower than what the checker inferred
TYPE_CHECKINGTrue only to static checkers, False at runtimeguarding imports only needed for annotations, to avoid circular imports
NewTypeCreates a distinct static-only type from an existing onee.g. UserId = NewType("UserId", int) to stop mixing up plain ints
assert_type / assert_neverStatic-only assertions checkers verify at type-check timetesting your own annotations, or asserting exhaustive match/if handling
reveal_type / get_type_hintsIntrospection — print inferred type / fetch resolved annotationsdebugging what a checker thinks a type is, or reading annotations at runtime
Please refer to the official documentation for details on any of this: https://docs.python.org/3/library/typing.html

Misc.

Classes as types

Classes can be considered as types too. All instances of a class Book together form the Book type. To use classes as types you simply use the name of the class.
ex.

from __future__ import annotations # for forward annotations
class Library:
    """This class models a simple Library"""
    @classmethod
    def create_entry(cls, book: Book) -> Library:
        pass

For more info, refer Object Oriented Python.

Annotating *args and **kwargs

You can annotate args and kwargs too. These represent a variable number of arguments.

def hello(*names: str) -> None:
    for name in list(names):
        print(f"Hello {name}!")

Callables

We may have already discussed this before in Functions, but functions are first-class objects (WHAT?) in Python. This means that you can use them as arguments in other functions.

If you use typing.Callable, Callable[[arg1, arg2, arg2], ret] represents a function with three arguments with types arg1, arg2 and arg3 with a return type of ret.
ex.

from typing import Callable
 
def demo(func: Callable[[str], str], argument: str) -> None:
    pass

Ending notes: some very light type theory

This is in no way a good starting point for type theory, but I will just note down some basic stuff. Fore more information, refer to PEP 483.

Subtypes

Formally, we say that a type T is a subtype of U if the following two conditions ohld:

  • Every value from T is also in the set of values of the U type.
  • Every function from U type is also in the set of functions of T type.

These two conditions guarantee that even if type T is different from U, variables of type T can always pretend to be U.

Consider the example T = bool and U = int. The bool type takes only two values, True or False. But those are just aliases for integers 1 and 0.
ex.

print(int(False))
# 0
print(issubclass(bool, int))
# True

The importance of subtypes is that a subtype can always pretend to be its supertype. For example, this code is valid:

def twice(number: int) -> int:
    return number * 2
 
print(twice(True)) # passing bool instead of int, still prints 2

All subclasses correspond to subtypes, and bool is a subtype of int because bool is a subclass of int. There are however subtypes that do not correspond to subclasses; ex. int is a subtype of float, but is not a subclass of float.

Covariant, Contravariant, Invariant

What happens when one uses subtypes inside composite types? For exmaple, is tuple[bool] a subtype of tuple[int]? The answer depends on the composite type, and whether that type is a covariant, a contravariant or an invariant type.
examples:

  • tuple is covariant. This means that it preserves the type hierarchy of its item types; tuple[bool] is a subtype of tuple[int] because bool is a subtype of int.
  • list is invariant. Invariant types don’t give any guarantee about subtypes. While all values of list[bool] are values of list[int], youc an append an int to list[int] but not to a list[bool]. The second condition for subtypes doesn’t hold here - list[bool] is not a subtype of list[int].
  • Callable is contravariant in its arguments. It means that it reverses the type hierarchy. Ex. Callable[[int], ...]’s example is the twice() function defined above. Being contravariant means taht if a function operating on a bool is expected, then a function operating on an `int would be acceptable.