Skip to content

Python – Type Hints

Type hints (PEP 484, PEP 526) are mandatory for all new Python code at Cygnus Dynamics. They serve as living documentation, catch bugs before runtime via mypy, power IDE autocompletion, and make code significantly easier to review and refactor.

Minimum Python version

All Cygnus Dynamics Python services target Python 3.11+. This page uses modern syntax available from 3.10+ (e.g. X | Y unions, list[str] built-in generics). For projects that must support 3.8/3.9, add from __future__ import annotations at the top of each file — it makes all annotations strings and unlocks the modern syntax without requiring a runtime upgrade.

Why Type Hints Matter

Python does not enforce type hints at runtime — they are resolved by static analysis tools. Their value comes from:

  • mypy / pyright — catch type mismatches, None dereferences, and wrong argument types before deployment
  • IDE autocompletion — PyCharm and VS Code use annotations for intelligent code completion and inline documentation
  • Readability — a function signature with annotations is self-documenting; readers know the expected types without reading the body
  • Refactoring safety — changing a function's return type immediately surfaces all callers that rely on the old type

Basic Annotations

Annotate all function parameters and return values. Annotate class attributes and module-level variables where the type is not immediately obvious.

# ✅ Annotated function signature
def get_order_total(items: list[OrderItem]) -> float:
    return sum(item.price * item.quantity for item in items)


# ✅ Annotated method
class OrderService:

    def __init__(self, repository: OrderRepository) -> None:   # __init__ always returns None
        self._repository = repository

    def get_order(self, order_id: int) -> Order:
        return self._repository.find_by_id(order_id)

    def find_orders_by_user(self, user_id: int) -> list[Order]:
        return self._repository.find_by_user(user_id)


# ✅ Annotated variable at class level
class PaymentConfig:
    timeout_seconds: int = 30
    max_retries: int = 3
    supported_currencies: frozenset[str] = frozenset({"USD", "EUR", "GBP"})

Annotation Formatting Rules

# ✅ No space before colon, one space after
def process(order: Order, notify: bool = True) -> None: ...

# ✅ Spaces around -> return arrow
def calculate_total(items: list[OrderItem]) -> float: ...

# ✅ Space around = when combining annotation with default
def create_order(currency: str = "USD", notify: bool = True) -> Order: ...

# ❌ Incorrect formatting
def process(order:Order)->None: ...           # no spaces around colon or arrow
def create_order(currency:str="USD")->Order:  # missing spaces everywhere

Built-in Generic Types (Python 3.9+)

Use lowercase built-in types directly — list, dict, set, tuple, frozenset. Do not import List, Dict, Set, Tuple from typing — those are deprecated as of Python 3.9.

# ✅ Modern built-in generics (Python 3.9+)
def get_users() -> list[User]:
    ...

def get_user_map() -> dict[int, User]:
    ...

def get_unique_tags() -> set[str]:
    ...

def get_coordinates() -> tuple[float, float]:
    ...

# ❌ Deprecated typing imports — do not use in new code
from typing import List, Dict, Set, Tuple

def get_users() -> List[User]:    # deprecated
    ...

def get_user_map() -> Dict[int, User]:    # deprecated
    ...

Optional Values — X | None

Use X | None (Python 3.10+) for values that may be absent. Do not use Optional[X] in new code — it is a longer alias for the same thing.

# ✅ Modern union with None (Python 3.10+)
def find_user_by_email(email: str) -> User | None:
    return self._repository.find_by_email(email)

# ✅ For 3.8/3.9 compatibility — add __future__ import at top of file
from __future__ import annotations
def find_user_by_email(email: str) -> User | None: ...

# ❌ Optional[X] — verbose alias, avoid in new code
from typing import Optional
def find_user_by_email(email: str) -> Optional[User]: ...  # same as User | None

Optional does not mean the parameter is optional

Optional[X] (or X | None) only means the type can be X or None. It does not mean the argument itself is optional in the function signature. An optional parameter (with a default value) and a nullable parameter (annotated X | None) are different things.

# The parameter has a default — it's optional to pass
def greet(name: str = "World") -> str: ...

# The parameter is required but its value may be None
def process(order: Order | None) -> None: ...

Union Types — X | Y

Use the | operator (Python 3.10+) to express a value that can be one of several types.

# ✅ Union with pipe syntax (Python 3.10+)
def parse_id(value: str | int) -> int:
    return int(value)

def get_config(key: str) -> str | int | bool:
    ...

# ❌ typing.Union — verbose, avoid in new code
from typing import Union
def parse_id(value: Union[str, int]) -> int: ...

Collections and Mappings

For abstract collection types in function signatures, prefer the collections.abc types over concrete implementations — this accepts any compatible type, not just list or dict:

from collections.abc import Sequence, Mapping, Iterable, Callable, Iterator

# ✅ Abstract — accepts list, tuple, or any Sequence
def process_items(items: Sequence[OrderItem]) -> None: ...

# ✅ Abstract — accepts dict, defaultdict, or any Mapping
def apply_config(settings: Mapping[str, str]) -> None: ...

# ✅ Abstract — accepts any iterable
def send_emails(recipients: Iterable[str]) -> None: ...

# ✅ For return types — be specific (caller knows what they get)
def get_items(order_id: int) -> list[OrderItem]: ...

TypedDict — Structured Dictionaries

When a dictionary has a fixed, known structure, use TypedDict instead of dict[str, Any]. This gives full type checking on key access.

from typing import TypedDict


class OrderSummary(TypedDict):
    order_id: int
    user_id: int
    total: float
    currency: str
    status: str


class ShippingAddress(TypedDict, total=False):  # total=False makes all keys optional
    line1: str
    line2: str
    city: str
    postcode: str
    country: str


def build_order_summary(order: Order) -> OrderSummary:
    return OrderSummary(
        order_id=order.id,
        user_id=order.user_id,
        total=order.total,
        currency=order.currency,
        status=order.status.value,
    )

Dataclasses and Pydantic Models

For data containers, prefer @dataclass (standard library) or pydantic.BaseModel (for validation) over plain classes or TypedDict.

from dataclasses import dataclass, field
from datetime import datetime


# ✅ Dataclass — immutable value object
@dataclass(frozen=True)
class Money:
    amount: float
    currency: str

    def __post_init__(self) -> None:
        if self.amount < 0:
            raise ValueError(f"Amount cannot be negative: {self.amount}")


# ✅ Dataclass with defaults
@dataclass
class OrderFilter:
    user_id: int | None = None
    status: str | None = None
    page: int = 1
    page_size: int = 20
    created_after: datetime | None = None
    tags: list[str] = field(default_factory=list)
from pydantic import BaseModel, field_validator, EmailStr


# ✅ Pydantic model — validated input from external sources
class CreateOrderRequest(BaseModel):
    user_id: int
    currency: str = "USD"
    items: list[OrderItemRequest]
    coupon_code: str | None = None

    @field_validator("currency")
    @classmethod
    def currency_must_be_supported(cls, value: str) -> str:
        if value not in {"USD", "EUR", "GBP"}:
            raise ValueError(f"Unsupported currency: {value}")
        return value

Callable Types

Annotate function parameters that accept callables using collections.abc.Callable:

from collections.abc import Callable


# ✅ Callable[[arg_types], return_type]
def retry(
    func: Callable[[], None],
    max_attempts: int = 3,
    on_failure: Callable[[Exception], None] | None = None,
) -> None:
    ...


# ✅ Callable with multiple parameters
def transform_orders(
    orders: list[Order],
    transform: Callable[[Order], Order],
) -> list[Order]:
    return [transform(order) for order in orders]

Protocols — Structural Typing

Use Protocol to express structural typing ("duck typing") — define what an object must be able to do, not what it must inherit from. This is the Pythonic alternative to interface classes.

from typing import Protocol, runtime_checkable


@runtime_checkable
class Notifiable(Protocol):
    """Any object that can receive a notification."""

    def send(self, recipient: str, message: str) -> bool: ...


class EmailNotifier:
    def send(self, recipient: str, message: str) -> bool:
        # send via email
        return True


class SMSNotifier:
    def send(self, recipient: str, message: str) -> bool:
        # send via SMS
        return True


# Both EmailNotifier and SMSNotifier satisfy the Protocol without inheriting it
def notify_user(user: User, notifier: Notifiable) -> None:
    notifier.send(user.email, f"Hello {user.first_name}!")

Generics

Use TypeVar with Generic to write reusable typed containers and utilities:

from typing import Generic, TypeVar

T = TypeVar("T")


class Repository(Generic[T]):
    """Generic base repository for any domain entity."""

    def find_by_id(self, entity_id: int) -> T | None: ...
    def save(self, entity: T) -> T: ...
    def delete(self, entity_id: int) -> None: ...


class OrderRepository(Repository[Order]):
    def find_by_user(self, user_id: int) -> list[Order]: ...


class UserRepository(Repository[User]):
    def find_by_email(self, email: str) -> User | None: ...

Any — Use Sparingly

Any disables all type checking for a value. Use it only when genuinely necessary — for example, when interfacing with a third-party library that has no type stubs.

from typing import Any

# ✅ Acceptable — external library with no stubs
def call_external_api(endpoint: str, payload: Any) -> Any: ...

# ❌ Overuse of Any — defeats the purpose of type hints
def process_order(order: Any, user: Any) -> Any: ...  # no type information at all

Never use Any to silence mypy errors

If mypy reports an error, fix the type correctly. Adding Any to make the error go away is technical debt — it silently removes the type safety from that code path. Use # type: ignore[error-code] with a comment explaining why, only as a last resort.

Annotating None Returns

Functions that return nothing must be annotated -> None. Never omit the return annotation.

# ✅ Explicit None return annotation
def send_notification(user: User, message: str) -> None:
    ...

def __init__(self, repository: OrderRepository) -> None:
    self._repository = repository

# ❌ Missing return annotation
def send_notification(user, message):  # no annotations at all
    ...

Quick Reference

Type Modern Syntax (3.10+) Older Equivalent
Optional value User \| None Optional[User]
Union of types str \| int \| bool Union[str, int, bool]
List of T list[str] List[str]
Dict K→V dict[str, int] Dict[str, int]
Set of T set[str] Set[str]
Fixed tuple tuple[int, str] Tuple[int, str]
Variable tuple tuple[int, ...] Tuple[int, ...]
Any callable Callable[..., T] same
Any sequence Sequence[T] same
Any mapping Mapping[K, V] same
Unknown / dynamic Any same — use sparingly
No return -> None same
Structural type Protocol same