Appendix T: Typing


Goals


Python is a dynamically typed language, which means that the type of a variable is determined at runtime.

It also means the type can change:

x = 1
x = "hello"  # not an error

This is convenient, but also a common source of bugs, since it can be difficult to keep track of what type a variable is.

x = f() # f used to return an int, but now returns a string
x / 2   # so now this raises an error

Static Typing

Many languages require variable definitions and function signatures to include type annotations.

// C
int f(int x) {
    return x + 1;
}
// Rust
fn f(x: i32) -> i32 {
    x + 1
}

This is called static typing, because the type is checked at compile time. Writing statically typed code can be more challenging at first, but will have fewer type-related errors since the language can enforce constraints.

Type Annotations

As you write larger programs it becomes more difficult and more important to know what types functions written by others are expected to receive & return.

While converting Python to a statically typed language isn’t possible or desirable, as people began to write larger Python programs the desire for something in-between grew.

This led to type annotations, one of Python’s newer features. Initial support was added in an experimental fashion about ten years ago, adoption began in earnest in the last 4-5.

For a sense of how they’ve evolved:

  • Python 3.5 (2015) introduced rudimentary type annotations and the typing module.
  • Python 3.6, 3.7, and 3.8 all contained incremental improvements for the type annotation system.
  • Python 3.9 (2020) made it possible to use collection types like list[str].
  • Python 3.10 (2021) introduced union syntax like int | float.
  • Python 3.11, 3.12, and 3.13 have introduced more advanced features, but the core functionality has stabilized.

Annotations are mostly used for function signatures, the def statement.

This lets us (and our tools) see at a glance what types are expected, both on inputs and outputs.

def f(x: int, y: str) -> int:
    print(y)
    return x + 1

Two new pieces of syntax:

  • After a variable definition (typically a function parameter) you can add a colon and the type. (: int)
  • Return type annotations can be placed after the closing parenthesis of the function signature with the -> int syntax.

It is also possible to annotate individual variables, particularly helpful when the type might not otherwise be clear.

x: int = f()

Enforcing Annotations

These annotations are also called type hints because they are not enforced. Unlike a statically-typed language like Rust, these are mere suggestions, Python will still happily take any value in an annotated function.

Instead, they serve a purpose similar to that of docstrings, meant as a reference.

The advantage they have over docstrings is that they are structured data. Your editor and other tools can evaluate them, checking compliance and warning about potential issues.

For example, mypy is a type checker, it works much like ruff the linter we introduced in Tools.

If we have some code with type annotations that are violated:

def f(x: int) -> str:
    return {"x": x}

f(3.0)

Running mypy on the above code will give you output like:

$ mypy test.py
test.py:3: error: Incompatible return value type (got "Dict[str, int]", expected "str")
test.py:5: error: Argument 1 to "f" has incompatible type "float"; expected "list"
Found 2 errors in 1 file (checked 1 source file)

Visual Studio Code: Pylance

In Visual Studio code, you can add automatic type checking via Microsoft’s Pylance plugin.

Writing Annotations

You can annotate with any of the built-in types:

  • int
  • float
  • str
  • bool
  • None
  • list
  • dict
  • set
  • tuple
  • etc.

The container types allow for annotating the type of the elements in the container with special syntax:

def f(x: list[int]) -> dict[str, int]:
    return {str(i): i for i in x}
  • list[int] - a list of ints
  • dict[str, int] - a dictionary with str keys and int values
  • tuple[int, str] - a two element tuple with an int and a str
  • set[tuple[int, int, int]] - set of tuples, each with three int

There are many more helper types in the typing module, for example:

  • typing.Any - any type
  • typing.Optional[int] - an int or None
  • typing.Union[int, str] - an int or a str
  • typing.Callable[[int, str], bool] - a function that takes an int and a str and returns a bool

You can also union types together with | (as of Python 3.10):

def f(x: int | str) -> int | str:
    """ this function takes integers or strings """
    return x

This means we can use | None as a shorter syntax for Optional:

def f(x: int | None) -> int | None:
    return x

Runtime Type Checking

Some libraries, such as the built in dataclasses module, pydantic, FastAPI, and typer are starting to use type annotations for runtime type checking.

from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str
    email: str

# these are type-checked
user = User(id=1, name="Sarah Connor", email="sarah@hotmail.com")
try:
    # note: id will be coerced to string since types are compatible
    user = User(id=1, name="Sarah Connor", email=None)
except Exception as e:
    print(e)
1 validation error for User
email
  Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]
    For further information visit https://errors.pydantic.dev/2.10/v/string_type

This allows you to catch errors earlier, and can result in less boilerplate code.

Further Exploration

More and more teams are adding type annotation to their Python code, if you write Python for a job there’s a good chance you’ll be asked to annotate your types. Additionally you will find that being able to read type annotations helps you read documentation for Python libraries, which typically denote the types of arguments using annotations.

For more details, see Python’s typing documentation.