Karam Assany's Personal Blog

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

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:

  1. 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.
  2. The developer is not capable of reading documentation and simply follows a 10-year-old Python video course.
  3. 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.


  1. If you’re interested, check this StackOverflow answer to for an overview of popular Python docstring conventions. ↩︎

  2. This explains why their website is named mypy-lang.org↩︎

  3. If you’re looking for a complete up-to-date documentation of Python’s static typing system, refer to the official typing documentation↩︎

  4. 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. ↩︎

  5. 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. ↩︎