On this page

Decorators and Type Hints

12 min read TextCh. 5 — Python for Web

Decorators and Type Hints

Two features separate intermediate Python from truly professional Python: type hints and decorators. Type hints make your code self-documenting, enable powerful tooling (autocompletion, static analysis), and catch bugs before they reach production. Decorators are a powerful metaprogramming tool that lets you modify or enhance functions and classes cleanly, without touching their source code.

Type Hints

Python's type hint system (introduced in 3.5, significantly expanded in 3.9, 3.10, and 3.12) allows you to annotate variables, function parameters, and return values with their expected types.

Type hints are not enforced at runtime — Python remains dynamically typed. They are used by:

  • Static type checkers like mypy, pyright, and pytype
  • IDE autocompletion engines
  • Documentation generators
  • Runtime validators like Pydantic
# Variable annotations
name: str = "Alice"
age: int = 30
pi: float = 3.14159
active: bool = True

# Function annotations
def greet(name: str, times: int = 1) -> str:
    return (f"Hello, {name}! " * times).strip()

def send_email(to: str, subject: str, body: str) -> None:
    print(f"Sending to {to}: {subject}")

Union Types

# Python 3.10+: use X | Y syntax
def divide(a: float, b: float) -> float | None:
    if b == 0:
        return None
    return a / b

# Before 3.10: from typing import Union
# def divide(a: float, b: float) -> Union[float, None]: ...

# Optional is a shorthand for X | None
from typing import Optional
def find_user(user_id: int) -> Optional[dict]:  # same as dict | None
    return None

Container Types

# Python 3.9+: use built-in generics directly
def sum_values(numbers: list[int]) -> int:
    return sum(numbers)

def get_config() -> dict[str, str]:
    return {"host": "localhost", "port": "8000"}

def process_pair(pair: tuple[str, int]) -> str:
    name, count = pair
    return f"{name}: {count}"

def get_tags() -> set[str]:
    return {"python", "web", "api"}

`TypeVar` — Generic Functions

from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T | None:
    """Return the first element of a list, or None if empty."""
    return items[0] if items else None

# Type checker knows the return type matches the input
x: int | None = first([1, 2, 3])       # x is int | None
y: str | None = first(["a", "b"])      # y is str | None

`Callable` Types

from typing import Callable

def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
    return func(a, b)

def add(x: int, y: int) -> int:
    return x + y

result = apply(add, 3, 4)   # type checker knows result is int

`TypedDict` — Typed Dictionaries

from typing import TypedDict

class UserData(TypedDict):
    id: int
    name: str
    email: str
    active: bool

def process_user(user: UserData) -> str:
    return f"Processing {user['name']} ({user['email']})"

# Type checkers will catch missing keys or wrong types
user_data: UserData = {"id": 1, "name": "Alice", "email": "[email protected]", "active": True}
print(process_user(user_data))

`Literal` and `Final`

from typing import Literal, Final

# Literal — restrict to specific values
def set_log_level(level: Literal["debug", "info", "warning", "error"]) -> None:
    print(f"Log level set to: {level}")

set_log_level("info")    # OK
# set_log_level("verbose")  # Type error!

# Final — marks a variable as a constant
MAX_RETRIES: Final[int] = 3
BASE_URL: Final[str] = "https://api.example.com"

tip type: info title: "Run mypy for static type checking"

Install mypy with pip install mypy and run mypy your_file.py. It will report type errors — things like passing a str where an int is expected — before you even run the code. For new projects, add mypy to your CI pipeline. Use --strict for maximum checking.

Decorators

A decorator is a function that takes a function as input, wraps it with additional behavior, and returns the enhanced function. This is the same higher-order function concept from Lesson 4, with a special @decorator syntax.

How Decorators Work

# A decorator is just a function that takes a function and returns a function
def shout(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()   # modify the return value
    return wrapper

def greet(name: str) -> str:
    return f"hello, {name}"

# Applying manually
loud_greet = shout(greet)
print(loud_greet("alice"))   # HELLO, ALICE

# Using the @ syntax — exactly equivalent
@shout
def greet2(name: str) -> str:
    return f"hello, {name}"

print(greet2("bob"))   # HELLO, BOB

`functools.wraps` — Preserving Metadata

Without @wraps, a decorated function loses its name, docstring, and other metadata. Always use @wraps:

import functools

def log_calls(func):
    @functools.wraps(func)   # preserves __name__, __doc__, __annotations__, etc.
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}{args}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result!r}")
        return result
    return wrapper

@log_calls
def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b

print(add(3, 4))
# Calling add(3, 4)
# add returned 7
# 7

# Metadata is preserved
print(add.__name__)   # add (not 'wrapper'!)
print(add.__doc__)    # Add two integers.

Decorators with Arguments

To create a decorator that takes its own arguments, you need an extra layer of nesting:

import functools
import time

def retry(max_attempts: int = 3, delay: float = 1.0):
    """Decorator factory — returns a decorator."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_error: Exception | None = None
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    last_error = e
                    print(f"Attempt {attempt}/{max_attempts} failed: {e}")
                    if attempt < max_attempts:
                        time.sleep(delay)
            raise RuntimeError(
                f"{func.__name__} failed after {max_attempts} attempts"
            ) from last_error
        return wrapper
    return decorator

import random

@retry(max_attempts=3, delay=0.1)
def flaky_operation() -> str:
    """Simulates an operation that sometimes fails."""
    if random.random() < 0.7:   # 70% chance of failure
        raise ConnectionError("Connection refused")
    return "Success!"

try:
    result = flaky_operation()
    print(result)
except RuntimeError as e:
    print(f"Gave up: {e}")

Practical Decorators

import functools
import time

# Timing decorator
def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

# Caching decorator
def memoize(func):
    cache: dict = {}
    @functools.wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

# Python's built-in lru_cache is much more powerful
from functools import lru_cache

@lru_cache(maxsize=128)
def fibonacci(n: int) -> int:
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

@timer
def compute_fibs() -> list[int]:
    return [fibonacci(i) for i in range(40)]

result = compute_fibs()
print(result[-1])   # 102334155
print(fibonacci.cache_info())  # CacheInfo(hits=..., misses=40, maxsize=128, currsize=40)

# Input validation decorator
def validate_positive(*param_names: str):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            import inspect
            sig = inspect.signature(func)
            bound = sig.bind(*args, **kwargs)
            bound.apply_defaults()
            for name in param_names:
                if name in bound.arguments and bound.arguments[name] <= 0:
                    raise ValueError(f"Parameter '{name}' must be positive, got {bound.arguments[name]}")
            return func(*args, **kwargs)
        return wrapper
    return decorator

@validate_positive("width", "height")
def create_canvas(width: int, height: int) -> str:
    return f"Canvas {width}x{height}"

print(create_canvas(800, 600))  # Canvas 800x600

try:
    create_canvas(-100, 600)
except ValueError as e:
    print(f"Error: {e}")   # Error: Parameter 'width' must be positive, got -100

Class Decorators

Decorators can also be applied to classes:

import functools

def singleton(cls):
    """Ensure only one instance of the class is ever created."""
    instances: dict = {}
    @functools.wraps(cls)
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return get_instance

@singleton
class DatabaseConnection:
    def __init__(self, url: str) -> None:
        self.url = url
        print(f"Connecting to {url}...")   # only printed once

    def query(self, sql: str) -> list:
        return []   # placeholder

db1 = DatabaseConnection("postgresql://localhost/mydb")
db2 = DatabaseConnection("postgresql://localhost/mydb")
print(db1 is db2)   # True — same instance

tip type: tip title: "Use dataclasses for simple class decorators"

Python's @dataclass decorator itself is a great example of a class decorator — it modifies the class by adding __init__, __repr__, and other methods automatically. Writing your own class decorators follows the same pattern: take the class, augment it, and return it.

nextSteps

  • python-final-project