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,Nonedereferences, 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.
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 |