This site is best experienced on a laptop or desktop.
All writing
Notes

Python Type Annotations: What I Actually Use and Why

A practical guide to Python type hints: where they help, where they get in the way and the specific patterns I reach for when annotating FastAPI handlers, dataclasses and utility functions.

1 September 20268 min read
Python
Types
Backend
FastAPI
Notes

Python's type annotation syntax was introduced in PEP 484 (Python 3.5) and has been expanded significantly in each subsequent release. In 2026 the ecosystem is mature enough that type annotations are worth using on any Python project that more than one person will touch, or that you will return to after more than a few weeks. These are the patterns I use day-to-day in the Phaemos backend (FastAPI) and in the system daemons that power the portfolio live status features.

The Basics: Function Signatures

Start by annotating function signatures. Parameter types and return types give mypy enough information to catch the most common errors: passing a string where an int is expected, forgetting to handle a None return, returning the wrong type from a function.

def parse_sensor_reading(raw: str) -> float:
    return float(raw.strip())

def format_status(temp: float, humidity: float) -> dict[str, float]:
    return {"temperature": temp, "humidity": humidity}

# Without annotation, this is valid Python that will fail at runtime:
result = parse_sensor_reading(42)  # mypy: Argument 1 to "parse_sensor_reading" has incompatible type "int"; expected "str"

Optional and Union

`Optional[T]` is shorthand for `T | None` (Python 3.10+ syntax). Use it whenever a value might not exist. The discipline of annotating Optional forces you to handle the None case explicitly, which catches a large class of AttributeError bugs at analysis time rather than in production.

from typing import Optional

def get_cached_value(key: str) -> Optional[str]:
    # Redis might return None if the key does not exist
    return redis_client.get(key)

# Python 3.10+ syntax (preferred if your version allows it):
def get_cached_value(key: str) -> str | None:
    return redis_client.get(key)

TypedDict for Structured Data

When a function receives or returns a dictionary with a known shape, TypedDict gives you type-checked keys without the overhead of a full class. I use this for Redis payloads in the Phaemos daemons where the data is structured but does not warrant a Pydantic model.

from typing import TypedDict

class SensorPayload(TypedDict):
    node_id: str
    temperature: float
    humidity: float
    timestamp: int

def publish_reading(payload: SensorPayload) -> None:
    redis.set(f"node:{payload['node_id']}:latest", json.dumps(payload))

Pydantic in FastAPI

FastAPI uses Pydantic models for request and response validation. Annotating your Pydantic models means the editor and mypy both know the shape of request bodies and response objects. FastAPI will also auto-generate OpenAPI documentation from the models. This is type annotations paying rent: you write the types once and get validation, documentation and editor support for free.

from pydantic import BaseModel, Field
from typing import Literal

class NodeReading(BaseModel):
    node_id: str = Field(..., min_length=1, max_length=50)
    temperature: float = Field(..., ge=-40.0, le=125.0)
    alert_level: Literal["normal", "warning", "critical"] = "normal"

@app.post("/readings")
async def submit_reading(reading: NodeReading) -> dict[str, str]:
    # reading.temperature is typed as float; FastAPI validated it on the way in
    return {"status": "ok", "node": reading.node_id}

Protocol for Duck Typing

`Protocol` (PEP 544) lets you define structural types without inheritance. A class satisfies a Protocol if it has the right methods - no explicit `implements` declaration needed. This is the right pattern for utility functions that should work with any object that has a specific interface.

from typing import Protocol

class Serialisable(Protocol):
    def to_dict(self) -> dict[str, object]: ...

def cache_object(obj: Serialisable, key: str) -> None:
    redis.set(key, json.dumps(obj.to_dict()))

# Any class with a to_dict() method satisfies Serialisable
# without inheriting from it

What Not to Over-Annotate

  • Local variables: annotating every local variable adds noise without helping mypy much; let it infer
  • Obvious returns: `def get_name() -> str:` is fine; `name: str = get_name()` on the next line is redundant
  • Type: ignore: use it rarely and add a comment explaining why; a proliferation of type: ignore comments defeats the purpose
  • Any: avoid it except at system boundaries (external APIs, dynamic config); propagating Any through your codebase silences errors instead of fixing them

References

  1. 01.PEP 484 - Type Hints (original specification)
  2. 02.PEP 544 - Protocols: Structural subtyping
  3. 03.mypy documentation - the standard Python static type checker
  4. 04.Python typing documentation - the official typing module reference
  5. 05.FastAPI - How FastAPI uses Pydantic and Python types
  6. 06.PEP 526 - Syntax for variable annotations (Python 3.6+)
  7. 07.Pyright - Microsoft's Python type checker, alternative to mypy

React to this post