History of Static Typing Syntax in Python
Published at:
Ever wondered why some Python codebases use List in type hints, while others
use list? Or why some codebases declare type variables using TypeVar while
others don’t? What about the from __future__ import annotation thing? Maybe
you wanted to know which syntax is supported in a given Python version, but all
you got is lengthy PEPs with no Next or Previous hyperlinks?
In this lengthy article I’ll review the history of static typing in the Python programming language. I will mostly discuss how the syntax evolved over the years; as the title implies. I intentionally omitted many details lest I make the article longer than necessary.
The Beginning ¶
Before Python 3.0, the only syntax that allowed programmers to annotate their code was simply comments and docstrings. For historical necessity, I’ll briefly discuss them here.
Docstrings ¶
There were many docstring conventions that allowed to “succinctly” annotate the types of parameters and return values of named functions. These conventions were not used by static type checkers; their only purpose was to document public APIs of Python libraries. Having a specific (although loose) syntax made it easier for documentation extractors/generators/converters to correctly format their outputs.
I’ll give one example:
def subtract(x, y):
"""
Compute the difference between two integers.
:param x: Minuend
:type x: int
:param y: Subtrahend
:type y: int
:return: Difference
:rtype: int
:raises ValueError: Either x or y is not an integer
"""
if not (isinstance(x, int) and isinstance(y, int)):
raise ValueError("Both x and y must be integers")
return x - y
This function’s docstring uses the Sphinx convention. It annotates the types
of parameters (resp. return value) using :type ARG: ... (resp. :rtype: ...).
Note that annotations themselves did not adhere to a specific syntax. For
example, to annotate a parameter that accepts either integers or lists of
integers, you can write int or list<int> or int | list[int]—whichever
is more readable to your audience.
Important note ¶
Most of these docstring conventions are still relevant and heavily used today in modern Python. Of course, instead of explicitly declaring the types in the docstring, document generators now extract type information directly from the function’s signature.1
Comments ¶
As opposed to docstrings which can only meaningfully annotate named functions,
comments can be used everywhere. type comment directives allowed programmers
to annotate almost every identifier in a way that a static type checker can
understand.
Here’s the previous example rewritten using type comment directives:
def subtract(x, y): # type: (int, int) -> int
return x - y
Here are other useful examples:
import time
alist = [] # type: list[int]
def get_current_time(): # type: () -> float
return time.time
Note that type comment directives were not that popular before Python 3.0.
At that time, static type checking for Python was a novel idea; there was no
specification for a static type system, and the syntax of type comment
directives was tied to the static type checker in use.
Some contemporary static type checkers still recognize and interpret these comment directives, mostly for backward compatibility.
Other solutions ¶
mypy, the most popular static type checker for Python, started as an extension to the Python language that supported type hints. Being a language extension means that Python could not run mypy code before mypy translates them into proper Python (by removing type annotations).2
Of course, many of these extensions were eventually implemented in the Python language itself.
2006-12-02 ¶
PEP 3107: Function Annotations got introduced in Python 3.0.
Syntax changes ¶
Function annotations were not limited to type hints, and in fact were used by many libraries, frameworks and tools for completely different purposes. Static type checkers used them as a way to statically type named functions without having to use a language extension or comment directives.
Here’s our subtract function; rewritten using function annotations:
def subtract(x: "int", y: "int") -> "int":
return x - y
Quoting annotations in strings is not required in the PEP (any Python expression can be used as an annotation), but it was needed to properly use nontrivial typing syntax without triggering runtime errors:
def sum(values: "list[int | float]") -> float:
result = 0.0
for value in values:
result += value
return result
def stringify(value: "str | list[str]") -> str:
if isinstance(value, list):
return "".join(value)
return value
Quoting was also necessary for forward references:
import types
class Node:
def get_parent(self) -> "Node": ...
def add_child(self, child: "Node") -> types.None: ...
Unquoting type hints in these example would not make a syntax error, but would make a runtime error when these function/class definitions are evaluated.
Using strings is safe at runtime because they simply evaluate to themselves, and type checkers knew how to parse quoted type hints. As quoting is harmless, many programmers chose to quote all type hints over thinking about what must be quoted and what can go without quotes.
Compared to comment directives ¶
Function annotations are cleaner than comment directives, as they are local to what’s being typed (i.e. you put the type hint right after the parameter).
Contrary to comment directives, and similar to docstrings, function annotations can be easily fetched at runtime, making them more useful for specific type-related use cases like runtime type checkers.
One remarkable enhancement that function annotations provide is the ability to omit type hints for specific parameters or the return value while providing them for others:
def subtract(x, y: "int") -> "int": ...
def subtract(x: "int", y: "int"): ...
def subtract(x: "int", y): ...
This allowed for parameter-level gradual typing as opposed to function-level gradual typing provided by comment directives (i.e. you either provide all type hints for a function or provide none).
Limits ¶
- Like comments, function annotations are general purpose and not tied to the case of static type hinting.
- There is still no standard way to write type hints.
- Function annotations can only be used on, well, functions.
Because of the third point, we still needed type comment directives to
annotate other kinds of identifiers.
2014-09-29 ¶
PEP 484: Type Hints got introduced in Python 3.5.
Theory ¶
Thanks to PEP 483, Python finally began to standardize static type hints. The PEP briefly explains the theory of gradual typing, subtype relationships, generics, etc.3
Syntax changes ¶
There is no syntax changes. PEP 484 only standardized the way function annotations can be utilized to provide type hints.
Other changes ¶
Let’s rewrite some of the previous examples to use PEP 484 type hints:
from typing import List, Union
def sum(values: List[float]) -> float:
result = 0.0
for value in values:
result += value
return result
def stringify(value: Union[str, List[str]]) -> str:
if isinstance(value, list):
return "".join(value)
return value
As you can see, quotes are no longer necessary in most cases (they are still needed in forward references).
PEP 484 introduced the typing standard module; which provided a variety of
constructs that enabled advanced type hints. Let’s have a very incomplete
walk-through of what the PEP has provided.
Annotating NoneType
¶
None is now used in type hints instead of types.NoneType or type(None).
Union types ¶
Union specifies a union type: typing.Union[str, int] is a union type of
str and int. Also we have typing.Optional[str] which is short for
typing.Union[str, None].
Type variables ¶
Generic types are defined using typing.TypeVar and typing.Generic:
from typing import TypeVar, Generic
T = TypeVar("T")
class Box(Generic[T]):
def __init__(self, name: str, content: T) -> None: ...
Explicit gradual typing ¶
Instead of omitting a type hint, we can explicitly use typing.Any:
from typing import Any, List
# These are equivalent:
def untyped_1(x: list): ...
def untyped_2(x: List[Any]) -> Any: ...
Stubs ¶
A restricted version of Python can now be used to provide type hints in
separate .pyi files (instead of embedding them in the source code). This
allowed us to use advanced typing syntax that isn’t (yet) available in the
Python version at use (as long as our type checker is up-to-date, of course).
Stubs are still used today, as many packages do not or cannot (in the case of so-called extension modules) embed type hints in their source code.
Limits ¶
There is still no way to annotate other kinds of identifiers without using
comment directives (or stub files). But at least this PEP standardized the
syntax for type comment directives.
2016-08-09 ¶
PEP 526: Syntax for Variable Annotations got introduced in Python 3.64.
Syntax changes ¶
Instead of using comment directives, one can do:
from typing import List
alist: List[int] = []
alist.append(67)
We can also define the type of a name before binding it:
# Normal assigment statements
length: int
if phone_number:
length = len(phone_number)
else:
length = -1
# So-called "fields"
class Person:
name: str
age: int
# `with` statements
db: DatabaseConnection
with connect(...) as db:
db.execute("...")
# `for` statements
item: str
for item in items:
print(item.upper())
Limits ¶
At this point we are finally free from using type comment directives (yay!).
In later Python versions, many non-syntax PEPs related to static typing and the
typing module were introduced to further enhance the static typing system
(most of which I’m going to leave out.)
This doesn’t mean that the syntax is final; in fact, two typing-related syntactical changes to the language happened after this PEP.
2017-09-08 ¶
PEP 563: Postponed Evaluation of Annotations got introduced in Python 3.7.
Syntax changes ¶
None.
Other changes ¶
This PEP introduced the famous from __future__ import annotations construct.
While nothing major syntax-wise, it is worth mentioning here because of the special effects it this future statement introduced.
The future statement simply stops the Python interpreter from evaluating annotations. Instead, they will be transparently quoted.
In other words, this code:
from __future__ import annotations
def subtract(x: int, y: int) -> int: ...
Is equivalent to this:
def subtract(x: "int", y: "int") -> "int": ...
Now the question is why?
At first glance, it appears to us that forward references are the only case
where quoting type hints is necessary. However, when you have a set of
modules, where each one uses types from the others, you will eventually face
circular import problems that can easily be solved by importing some types
inside an if typing.TYPE_CHECKING statement and quoting type hints that use
these types.
With postponed evaluation of annotation, we can simply move all type imports
inside an if typing.TYPE_CHECKING statement. This prevents a lot of
headache; and we don’t have to use ugly strings as type hints.
This mechanism also improves runtime performance, as the Python interpreter will not spend time evaluating type hints that are only useful for static type checkers.5
2019-03-03 ¶
PEP 585: Type Hinting Generics In Standard Collections got introduced in Python 3.9.
Syntax changes ¶
None.
Other changes ¶
Instead of using typing.List[T], typing.Type[T], typing.Iterator etc, one
can simply use list[T], type[T], collections.abc.Iterator[T] etc. This
helped removing a lot of useless import statements, and made type hints more
concise and elegant.
Let’s update our stringify function accordingly:
from typing import Union
def stringify(value: Union[str, list[str]]) -> str:
if isinstance(value, list):
return "".join(value)
return value
2019-08-28 ¶
PEP 604: Allow writing union types as X | Y got introduced in Python 3.10.
Syntax changes ¶
None.
Other changes ¶
Instead of using typing.Union[A, B, C], one can use A | B | C, which is
prettier and doesn’t need to be imported.
Here’s the final version of the stringify function:
def stringify(value: str | list[str]) -> str:
if isinstance(value, list):
return "".join(value)
return value
Of course, instead of using the confusing typing.Optional[T] construct, now
we can simply go with T | None.
2022-06-15 ¶
PEP 695: Type Parameter Syntax got introduced in Python 3.12.
Syntax changes ¶
We now have a pretty way to type hint generic classes and functions.
Let’s take two examples:
def get_first[T](alist: list[T]) -> T:
return alist[0]
class Box[T]:
def __init__(self, name: str, content: T) -> None: ...
No need to use verbose typing.TypeVars!
What’s more important is that we no longer have to declare whether a type variable in covariant, contravariant or invariant. This is now the type checker’s job.
Another syntactical change is type statements, which are an explicit way to
define type aliases:
type Char = str | bytes
# Generic type aliases
type ListOrTuple[T] = list[T] | tuple[T]
2024-05-28 ¶
PEP 749: Implementing PEP 649 got introduced in Python 3.14.
Syntax changes ¶
None.
Other changes ¶
To simplify and summarize: Python now lazily interprets type hints, almost as
if from __future__ import annotations is mentioned at the top of every
module.
For the most part, this means that we longer have to care about writing
from __future__ import annotations everywhere—nullifying the possibility of
accidentally missing it out somewhere.
Where Are We Now? ¶
At the time of writing this article, Python 3.13 is going to reach end-of-life in a few months. It will still receive security updates for three and a half years.
Six months ago, Python 3.9 stopped receiving security updates, and most maintained libraries and frameworks dropped support for it.
This means that it’s always safe to write list[str | int] instead of
List[Union[str, int]]. In fact, if you see new Python code that uses the
latter, you can assume one of the following:
- The code is AI-generated: I don’t know exactly why but it seems that LLMs are trained on ancient codebases and having a hard time moving on.
- The developer is not capable of reading documentation and simply follows a 10-year-old Python video course.
- The developer is living under a rock.
You’re free to decide which one as they’re all equally bad.
Sadly, the new syntax for generics and type aliases isn’t safe to use if you still want to support Python 3.11, which already reached end-of-life but is still receiving security updates for one and a half years.
Notice ¶
I will try my best to keep this article up-to-date whenever a new Python version get released. However, life is hard and thus you should always check the Updated at field under the article’s title to make sure the information is not outdated.
-
If you’re interested, check this StackOverflow answer to for an overview of popular Python docstring conventions. ↩︎
-
This explains why their website is named
mypy-lang.org. ↩︎ -
If you’re looking for a complete up-to-date documentation of Python’s static typing system, refer to the official typing documentation. ↩︎
-
The term variable is used a lot in Python’s literature to refer to names bound to values through assignment statements (and in Python 3.8, assignment expressions). I will explain why I disagree with this term in a later article. ↩︎
-
In case type hints were needed at runtime (e.g. to be used by a runtime type checker), they could simply be evaluated on-demand. ↩︎