Create your own
Lesson illustration

Type Annotations for Incident-Domain Code

Hello again. In the previous lesson, you made the project environment reproducible: FastAPI is a runtime dependency, quality tools such as mypy are development dependencies, and uv.lock records the resolved package graph. Now we start making the code itself more explicit.

This lesson focuses on precise type annotations for incident-domain functions: what values a function accepts, what it returns, what is inside its collections, and where absence is a valid state. These annotations will later serve several purposes at once: clearer internal contracts, better editor support, static checking, and FastAPI request handling.


Type annotations are executable documentation for collaborators

Python remains dynamically typed. An annotation such as title: str does not cause the Python interpreter to reject an integer automatically. Instead, annotations describe the contract that humans, editors, and static tools such as mypy can inspect.

Consider this untyped helper:

def normalize_title(title):
    return title.strip()

From the body, an experienced reader can infer that title should probably be text. But that knowledge is implicit. A caller might pass None, an integer, or an object obtained from an unvalidated payload. The failure occurs only when this line runs.

A typed version states the intended contract directly:

def normalize_title(title: str) -> str:
    return title.strip()

The annotation has two parts:

  • title: str says the function expects a string.
  • -> str says the function returns a string.

For application code, annotate every function parameter and every return value. This is especially valuable at module boundaries: public helpers, service methods, repository methods, and functions that transform external data into domain values.

Python Tutorial: Type Hints - From Basic Annotations to Advanced Generics

Watch Python Tutorial: Type Hints – From Basic Annotations to Advanced Generics by Corey Schafer for a concise demonstration of why annotations improve code navigation and how function annotations and optional values work in practice.

Start with the motivation for annotations and static checking. Then watch basic contracts, focusing on parameter types, return types, and why a parameter with a default value of None must explicitly allow None.

A useful rule is:

Annotate contracts and ambiguity, not every obvious local variable.

This local annotation usually adds little:

title: str = "Database latency"

The assigned string already makes the type obvious. But an empty collection, a value initialized as None, or a complex result often benefits from an explicit annotation.


Start with small, honest incident-domain contracts

Create a temporary module such as src/incident_api/incident_typing.py. It is deliberately a small collection of domain helpers rather than an API model or database model; those layers come later.

from collections.abc import Iterable, Mapping
from uuid import UUID


def normalize_title(title: str) -> str:
    normalized = title.strip()
    if not normalized:
        raise ValueError("Incident title must not be blank")
    return normalized


def count_incidents_by_status(statuses: Iterable[str]) -> dict[str, int]:
    counts: dict[str, int] = {}

    for status in statuses:
        counts[status] = counts.get(status, 0) + 1

    return counts


def find_assignee_email(
    emails_by_user_id: Mapping[UUID, str],
    assignee_id: UUID | None,
) -> str | None:
    if assignee_id is None:
        return None

    return emails_by_user_id.get(assignee_id)


def format_assignee_label(assignee_name: str | None) -> str:
    if assignee_name is None:
        return "Unassigned"

    return assignee_name.strip() or "Unassigned"

Notice what this communicates without needing comments:

FunctionContract expressed by the annotations
normalize_titleTakes text and returns normalized text, or raises an error for blank input.
count_incidents_by_statusConsumes any iterable of status strings and returns counts keyed by status.
find_assignee_emailLooks up a user ID if one exists; either the input ID or the returned email may be absent.
format_assignee_labelAccepts an optional name but always produces a displayable string.

The UUID type is more precise than str for an identifier already represented as a UUID object. It helps prevent a common maintenance mistake: accidentally passing a title, status, or email where a user or incident identifier is expected.

For the moment, status is still typed as str. That is honest about the current code, but not sufficient to restrict the vocabulary to values such as "new" or "resolved". The next lesson will improve that contract with Enum rather than leaving status as arbitrary text.


Make collection annotations describe both structure and contents

A bare annotation such as list says almost nothing useful. Is it a list of incident IDs, raw JSON-like dictionaries, user objects, or a mixture of all of them?

Modern Python uses built-in generic collection types:

IntentPrecise annotation
List of incident titleslist[str]
Set of affected user IDsset[UUID]
Status-count mappingdict[str, int]
Fixed pair: incident ID and titletuple[UUID, str]
Tuple of any number of tagstuple[str, ...]

The type inside square brackets matters. It gives the reader and type checker information about each contained value.

def visible_tags(tags: set[str]) -> list[str]:
    return sorted(tags)

Within this function, a type-aware editor knows that each value obtained from tags is a str, so string operations are available and invalid operations are flagged early.

Choose the least restrictive collection interface that the function needs

The concrete type list[str] is appropriate when a function genuinely needs list-specific behavior, such as appending, deleting by position, or mutating an existing list.

But a function that only loops through values does not need a list. It should say so:

from collections.abc import Iterable


def count_incidents_by_status(statuses: Iterable[str]) -> dict[str, int]:
    counts: dict[str, int] = {}

    for status in statuses:
        counts[status] = counts.get(status, 0) + 1

    return counts

Iterable[str] means “anything that can be iterated over and produces strings.” The caller can provide a list, tuple, set, generator, or a streamed iterable. That makes the helper easier to reuse without weakening information about its elements.

Similarly, use Mapping when the function needs key-based lookup but does not mutate the supplied mapping:

from collections.abc import Mapping
from uuid import UUID


def find_assignee_email(
    emails_by_user_id: Mapping[UUID, str],
    assignee_id: UUID | None,
) -> str | None:
    if assignee_id is None:
        return None

    return emails_by_user_id.get(assignee_id)

A normal dict[UUID, str] can be passed to this function because it behaves as a mapping. But Mapping tells callers something important: this function promises not to add, replace, or delete entries through that argument.

Use the collection type that reflects the operations performed:

If the function needs to...Prefer
Loop once over valuesIterable[T]
Read by position or use len() without mutationSequence[T]
Look up values by key without mutationMapping[K, V]
Add, remove, or replace items in a caller-provided listlist[T]
Add or replace key-value pairsMutableMapping[K, V]

This is similar to defining a minimal interface for an infrastructure component: require only the capabilities the component actually needs. A narrow interface makes a function more reusable and reduces accidental coupling.


Optional means “this value may be absent”

In Python type annotations, None is a real possible value and must be stated explicitly.

assignee_id: UUID | None

This means that assignee_id can contain either a UUID or None.

On Python 3.10 and newer, X | None is the preferred spelling. You will also encounter the older equivalent in existing projects:

from typing import Optional

assignee_id: Optional[UUID]

Both mean the same thing. For this project, prefer UUID | None when the configured Python version supports it.

Do not confuse a nullable value with an omittable argument:

def assign(
    incident_id: UUID,
    assignee_id: UUID | None,
) -> None:
    ...

Here, callers must provide assignee_id, but they may explicitly provide None.

def assign(
    incident_id: UUID,
    assignee_id: UUID | None = None,
) -> None:
    ...

Here, callers may omit assignee_id; its value becomes None.

Conversely, this parameter is omittable but never None:

def list_recent_incidents(limit: int = 20) -> list[UUID]:
    ...

The default controls whether the argument can be omitted. The annotation controls which values are valid when the function is called.

Narrow an optional value before using it

This is an unsafe implementation:

def format_assignee_label(assignee_name: str | None) -> str:
    return assignee_name.strip()

If assignee_name is None, .strip() does not exist and the function fails at runtime. A type checker should warn about that possibility.

Instead, handle absence explicitly:

def format_assignee_label(assignee_name: str | None) -> str:
    if assignee_name is None:
        return "Unassigned"

    return assignee_name.strip() or "Unassigned"

After the if assignee_name is None branch returns, a type checker can narrow the remaining type from str | None to str.

Prefer is None when absence has distinct domain meaning. A check such as if not assignee_name also treats an empty string as false, which can blur two different cases:

  • no assignee name exists;
  • a name was supplied but is blank or invalid.

That distinction becomes particularly important later when implementing PATCH semantics, where omitted fields, explicit null, and empty strings can mean different things.


Avoid false precision and avoid Any by default

A type annotation should represent a real contract, not merely silence a warning.

For example, this says that every key is a string and every value is either a string or absent:

dict[str, str | None]

It does not describe a structured incident record with specific keys such as title, status, and assignee_id. It is only appropriate when the data truly is a general-purpose string mapping.

Likewise, avoid bare containers:

def summarize(items: list) -> dict:
    ...

and treat Any as a deliberate boundary escape hatch, not a default:

from typing import Any


def process_payload(payload: Any) -> None:
    ...

Any effectively tells the type checker to stop checking operations on that value. It is sometimes unavoidable when receiving an unvalidated external payload, but domain code should convert such data into specific types as early as possible.

For now, the goal is modest and practical:

  1. Use specific scalar types such as str, int, bool, and UUID.
  2. State element, key, and value types for collections.
  3. Use T | None only where absence is an intentional possibility.
  4. Use abstract collection interfaces when the function does not require a concrete mutable container.
  5. Ensure the return annotation matches every possible return path.

Implementation checkpoint

Add the four typed helpers to incident_typing.py, then inspect each annotation as if you were reviewing a pull request:

  • Does the function require a list, or only something iterable?
  • Does every collection state what it contains?
  • Is None accepted because it has a real business meaning, rather than because it is convenient?
  • Does the declared return type cover all branches?

You can verify that the module has valid Python syntax without introducing test infrastructure yet:

uv run python -m compileall src

This only checks that Python can compile the source. It does not perform static type analysis. Later in this module, you will configure mypy and use it to identify mismatches between these contracts and the implementation.


Key takeaways

Type annotations make domain code easier to review and safer to evolve:

  • Annotate function parameters and return values to state clear contracts.
  • Specify collection contents, such as list[str], dict[str, int], and set[UUID].
  • Prefer Iterable, Sequence, and Mapping when a function does not require a concrete mutable collection.
  • Express deliberate absence with T | None, and narrow the value with an explicit is None check before using it as T.
  • A default value determines whether an argument may be omitted; | None determines whether None is valid.
  • Annotations improve tooling and static analysis, but they do not themselves validate runtime input.

Next, you will make incident attributes such as severity and status more constrained by representing them with Enum types.

Can't find a good explanation? Sign up and we'll make it for you

Sign up