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 fineDuck 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)) # 200Note 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 timeNote 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:
passYou 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"""
passThis 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"""
passNOTE: 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:
| ABC | Guarantees | Use 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 + Container | len, iterate, and in |
Sequence[T] | __getitem__ + __len__, ordered | indexing/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-only | dict-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 operations | union/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/throw | specifically 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, strA 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.
| Construct | What it does | Use it when |
|---|---|---|
Any | Disables type checking for that value entirely | (avoid) — only as an escape hatch when a type is genuinely unknowable |
X | Y | Union of types (replaces Union[X, Y]) | a value can be one of several types |
X | None | Optional value (replaces Optional[X]) | a value can be absent |
TypeVar / Generic | Legacy way to declare generics | rarely - 3.12+ native syntax (class Box[T]:, def first[T](...)) replaces this for new code |
ParamSpec | Captures a callable’s full parameter signature | writing a decorator that must preserve the wrapped function’s signature |
Concatenate | Prepends fixed params to a ParamSpec in a Callable type | a decorator adds/removes a leading argument (e.g. injecting self) |
TypeVarTuple / Unpack | Variadic generics (*Ts in 3.12+ syntax) / typed **kwargs | a generic needs an arbitrary number of type params, or you’re typing **kwargs against a TypedDict |
Literal | Restricts a value to specific literal constants | e.g. mode: Literal["r", "w", "rb", "wb"] |
TypedDict | Dict with a fixed, typed set of keys | representing structured dict data (e.g. parsed JSON) without a class |
ReadOnly / Required / NotRequired | Field-level modifiers inside a TypedDict (3.13+ for ReadOnly) | marking specific TypedDict keys as immutable or optional |
NamedTuple | Typed, immutable tuple with named fields | a lightweight immutable record - like a frozen dataclass |
Final | Marks a name as non-reassignable / non-overridable | constants, or preventing a subclass from overriding a method |
ClassVar | Marks an attribute as class-level, not instance-level | annotating class variables inside a class body |
Self (3.11+) | Refers to the enclosing class’s own type | methods that return self or a new instance of the same class (esp. with inheritance) |
TypeGuard | Custom 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 |
overload | Declares multiple call signatures for one function | a function’s return type depends on which arguments/types are passed |
runtime_checkable | Lets a Protocol be used with isinstance() | (see Protocols) |
cast | Tells the checker “trust me, this is type X” (no runtime effect) | you know a type is narrower than what the checker inferred |
TYPE_CHECKING | True only to static checkers, False at runtime | guarding imports only needed for annotations, to avoid circular imports |
NewType | Creates a distinct static-only type from an existing one | e.g. UserId = NewType("UserId", int) to stop mixing up plain ints |
assert_type / assert_never | Static-only assertions checkers verify at type-check time | testing your own annotations, or asserting exhaustive match/if handling |
reveal_type / get_type_hints | Introspection — print inferred type / fetch resolved annotations | debugging 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:
passFor 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:
passEnding 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
Tis also in the set of values of theUtype. - Every function from
Utype is also in the set of functions ofTtype.
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))
# TrueThe 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 2All 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:
tupleis covariant. This means that it preserves the type hierarchy of its item types;tuple[bool]is a subtype oftuple[int]becauseboolis a subtype ofint.listis invariant. Invariant types don’t give any guarantee about subtypes. While all values oflist[bool]are values oflist[int], youc an append aninttolist[int]but not to alist[bool]. The second condition for subtypes doesn’t hold here -list[bool]is not a subtype oflist[int].Callableis contravariant in its arguments. It means that it reverses the type hierarchy. Ex.Callable[[int], ...]’s example is thetwice()function defined above. Being contravariant means taht if a function operating on aboolis expected, then a function operating on an `int would be acceptable.