On this page
Decorators and Type Hints
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 NoneContainer 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 mypyand runmypy your_file.py. It will report type errors — things like passing astrwhere anintis expected — before you even run the code. For new projects, addmypyto your CI pipeline. Use--strictfor 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 -100Class 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 instancetip type: tip title: "Use dataclasses for simple class decorators"
Python's
@dataclassdecorator 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
Sign in to track your progress