Skip to main content Brad's PyNotes

PEP 526: Variable Annotations - Type Hints for Variables

TL;DR

PEP 526 introduced variable annotations in Python 3.6, allowing type hints for variables using the syntax variable: type = value, extending PEP 484’s function annotations to all variables for better code documentation and static analysis.

Interesting!

Variable annotations create a special __annotations__ dictionary on classes and modules that stores the type information at runtime, making it accessible for tools, IDEs, and introspection - but the annotations themselves don’t affect runtime behavior!

Variable Annotation Syntax

Basic Variable Annotations

python code snippet start

# Simple variable annotations
name: str = "Alice"
age: int = 30
height: float = 5.6
is_active: bool = True

# Annotations without initial values
user_id: int
email: str
profile_data: dict

# Later assignment
user_id = 12345
email = "alice@example.com"
profile_data = {"theme": "dark", "notifications": True}

# Multiple annotations in sequence
first_name: str
last_name: str
full_name: str

first_name = "Alice"
last_name = "Smith"
full_name = f"{first_name} {last_name}"

python code snippet end

Complex Type Annotations

python code snippet start

from typing import List, Dict, Optional, Union, Tuple, Set

# Collection types
numbers: List[int] = [1, 2, 3, 4, 5]
scores: Dict[str, float] = {"math": 95.5, "science": 87.2}
coordinates: Tuple[float, float] = (10.5, 20.3)
unique_tags: Set[str] = {"python", "programming", "tutorial"}

# Optional and Union types
middle_name: Optional[str] = None  # Can be str or None
result: Union[str, int] = "success"  # Can be str or int

# Nested collections
matrix: List[List[int]] = [[1, 2], [3, 4], [5, 6]]
user_groups: Dict[str, List[str]] = {
    "admins": ["alice", "bob"],
    "users": ["charlie", "diana"]
}

# Function types
from typing import Callable
callback: Callable[[int, str], bool] = lambda x, y: len(y) > x
processor: Callable[..., None]  # Variable arguments

python code snippet end

Class Variable Annotations

Instance vs Class Variables

python code snippet start

from typing import ClassVar, List, Optional

class User:
    # Class variable annotations
    total_users: ClassVar[int] = 0
    default_role: ClassVar[str] = "user"
    
    # Instance variable annotations
    username: str
    email: str
    age: Optional[int]
    roles: List[str]
    
    def __init__(self, username: str, email: str):
        self.username = username
        self.email = email
        self.age = None  # Optional, can be set later
        self.roles = []
        
        # Update class variable
        User.total_users += 1
    
    def add_role(self, role: str) -> None:
        self.roles.append(role)
    
    def get_display_name(self) -> str:
        return f"{self.username} ({self.email})"

# Usage
alice = User("alice", "alice@example.com")
alice.age = 30
alice.add_role("admin")

print(f"Total users: {User.total_users}")
print(f"Alice's info: {alice.get_display_name()}")

python code snippet end

Advanced Class Annotations

python code snippet start

from typing import Dict, Any, Protocol, TypeVar
from dataclasses import dataclass, field

# Protocol for type checking
class Drawable(Protocol):
    def draw(self) -> str: ...

# Generic type variable
T = TypeVar('T')

@dataclass
class Repository:
    """Generic repository with type annotations."""
    
    # Class variables
    connection_pool: ClassVar[Dict[str, Any]] = {}
    
    # Instance variables with defaults
    name: str
    items: List[T] = field(default_factory=list)
    metadata: Dict[str, Any] = field(default_factory=dict)
    is_readonly: bool = False
    
    def add_item(self, item: T) -> None:
        if not self.is_readonly:
            self.items.append(item)
    
    def get_items(self) -> List[T]:
        return self.items.copy()
    
    def count(self) -> int:
        return len(self.items)

# Specialized repositories
user_repo: Repository[User] = Repository("users")
config_repo: Repository[Dict[str, str]] = Repository("config")

user_repo.add_item(alice)
config_repo.add_item({"theme": "dark", "language": "en"})

python code snippet end

Module-Level Annotations

Global Variable Annotations

python code snippet start

# Module-level variable annotations
from typing import Dict, List, Optional
import os

# Configuration variables
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
MAX_CONNECTIONS: int = int(os.getenv("MAX_CONNECTIONS", "100"))
DATABASE_URL: Optional[str] = os.getenv("DATABASE_URL")

# Application state
active_sessions: Dict[str, Dict[str, Any]] = {}
request_queue: List[Dict[str, Any]] = []
error_log: List[str] = []

# Cached data
_cache: Dict[str, Any] = {}
_cache_expiry: Optional[float] = None

def initialize_app() -> None:
    """Initialize application with proper type checking."""
    global active_sessions, request_queue
    
    active_sessions.clear()
    request_queue.clear()
    
    if DEBUG:
        print("Application initialized in debug mode")

def add_session(session_id: str, user_data: Dict[str, Any]) -> None:
    """Add a new session with type safety."""
    active_sessions[session_id] = {
        "user_data": user_data,
        "created_at": time.time(),
        "last_activity": time.time()
    }

# Check annotations at module level
print(f"Module annotations: {__annotations__}")

python code snippet end

Configuration Management

python code snippet start

from typing import NamedTuple, Literal, get_type_hints
import json
from pathlib import Path

# Type-safe configuration using annotations
class AppConfig:
    """Application configuration with type annotations."""
    
    # Server settings
    host: str = "localhost"
    port: int = 8000
    debug: bool = False
    
    # Database settings  
    db_host: str = "localhost"
    db_port: int = 5432
    db_name: str = "myapp"
    db_user: str = "postgres"
    db_password: str = ""
    
    # Feature flags
    enable_caching: bool = True
    enable_logging: bool = True
    log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
    
    # Limits
    max_upload_size: int = 10 * 1024 * 1024  # 10MB
    session_timeout: int = 3600  # 1 hour
    
    @classmethod
    def from_file(cls, config_path: Path) -> 'AppConfig':
        """Load configuration from JSON file with type validation."""
        config = cls()
        
        if config_path.exists():
            with open(config_path) as f:
                data = json.load(f)
            
            # Get type hints for validation
            hints = get_type_hints(cls)
            
            for key, value in data.items():
                if hasattr(config, key):
                    expected_type = hints.get(key)
                    if expected_type and not isinstance(value, expected_type):
                        # Simple type conversion for basic types
                        if expected_type == bool and isinstance(value, str):
                            value = value.lower() in ('true', '1', 'yes')
                        elif expected_type in (int, float, str):
                            value = expected_type(value)
                    
                    setattr(config, key, value)
        
        return config
    
    def to_dict(self) -> Dict[str, Any]:
        """Convert config to dictionary."""
        return {
            key: getattr(self, key) 
            for key in get_type_hints(self.__class__)
            if not key.startswith('_')
        }

# Usage
config: AppConfig = AppConfig.from_file(Path("config.json"))
print(f"Server will run on {config.host}:{config.port}")
print(f"Debug mode: {config.debug}")

python code snippet end

Runtime Introspection of Annotations

Accessing Annotations

python code snippet start

from typing import get_type_hints, get_origin, get_args
import inspect

class DataProcessor:
    """Example class with comprehensive annotations."""
    
    batch_size: int = 100
    input_format: str = "json"
    processors: List[Callable[[Dict], Dict]] = []
    
    def __init__(self, name: str):
        self.name: str = name
        self.processed_count: int = 0
        self.errors: List[str] = []

def analyze_annotations(obj: Any) -> Dict[str, Any]:
    """Analyze annotations of an object."""
    result = {}
    
    # Get annotations dictionary
    if hasattr(obj, '__annotations__'):
        result['raw_annotations'] = obj.__annotations__
    
    # Get resolved type hints
    try:
        result['type_hints'] = get_type_hints(obj)
    except (NameError, AttributeError):
        result['type_hints'] = {}
    
    # Analyze each annotation
    detailed_info = {}
    for name, annotation in result.get('type_hints', {}).items():
        info = {
            'annotation': annotation,
            'origin': get_origin(annotation),
            'args': get_args(annotation),
            'is_generic': hasattr(annotation, '__origin__'),
        }
        
        # Check if it has a default value
        if hasattr(obj, name):
            info['has_default'] = True
            info['default_value'] = getattr(obj, name)
        else:
            info['has_default'] = False
        
        detailed_info[name] = info
    
    result['detailed_info'] = detailed_info
    return result

# Analyze our class
processor_info = analyze_annotations(DataProcessor)
print("DataProcessor annotations analysis:")
for name, info in processor_info['detailed_info'].items():
    print(f"  {name}: {info['annotation']}")
    if info['has_default']:
        print(f"    Default: {info['default_value']}")
    if info['origin']:
        print(f"    Origin: {info['origin']}, Args: {info['args']}")

python code snippet end

Dynamic Type Checking

python code snippet start

from typing import Any, Union, get_origin, get_args
import inspect

def validate_annotation(value: Any, annotation: Any) -> bool:
    """Validate if a value matches its annotation."""
    
    # Handle None/Optional
    if value is None:
        return annotation is type(None) or (
            get_origin(annotation) is Union and
            type(None) in get_args(annotation)
        )
    
    # Handle basic types
    if isinstance(annotation, type):
        return isinstance(value, annotation)
    
    # Handle generic types
    origin = get_origin(annotation)
    args = get_args(annotation)
    
    if origin is Union:
        return any(validate_annotation(value, arg) for arg in args)
    
    if origin is list:
        if not isinstance(value, list):
            return False
        if args:  # Check element types
            return all(validate_annotation(item, args[0]) for item in value)
        return True
    
    if origin is dict:
        if not isinstance(value, dict):
            return False
        if len(args) == 2:  # Check key and value types
            key_type, value_type = args
            return all(
                validate_annotation(k, key_type) and validate_annotation(v, value_type)
                for k, v in value.items()
            )
        return True
    
    # Default: try isinstance
    try:
        return isinstance(value, annotation)
    except TypeError:
        return True  # Can't validate, assume valid

class TypedContainer:
    """Container that validates types based on annotations."""
    
    def __init__(self):
        self._annotations = get_type_hints(self.__class__)
    
    def __setattr__(self, name: str, value: Any) -> None:
        # Skip private attributes and during initialization
        if name.startswith('_') or '_annotations' not in self.__dict__:
            super().__setattr__(name, value)
            return
        
        # Validate against annotation if it exists
        if name in self._annotations:
            expected_type = self._annotations[name]
            if not validate_annotation(value, expected_type):
                raise TypeError(
                    f"Invalid type for {name}: expected {expected_type}, got {type(value)}"
                )
        
        super().__setattr__(name, value)

class StrictUser(TypedContainer):
    """User class with runtime type checking."""
    
    name: str
    age: int
    emails: List[str]
    metadata: Optional[Dict[str, Any]]
    
    def __init__(self, name: str, age: int):
        super().__init__()
        self.name = name
        self.age = age
        self.emails = []
        self.metadata = None

# Test the validation
try:
    user = StrictUser("Alice", 30)
    user.emails = ["alice@example.com", "alice@work.com"]  # Valid
    user.age = "thirty"  # This will raise TypeError
except TypeError as e:
    print(f"Type error caught: {e}")

python code snippet end

Integration with Type Checkers

MyPy Integration

python code snippet start

# mypy_example.py
from typing import List, Dict, Optional, Protocol
import json

# Protocol definition
class Serializable(Protocol):
    def to_dict(self) -> Dict[str, Any]: ...
    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> 'Serializable': ...

class Product:
    """Product class with comprehensive type annotations."""
    
    # Class variables
    next_id: int = 1
    
    def __init__(self, name: str, price: float, category: str):
        self.id: int = Product.next_id
        Product.next_id += 1
        
        self.name: str = name
        self.price: float = price
        self.category: str = category
        self.tags: List[str] = []
        self.metadata: Dict[str, Any] = {}
        self.is_active: bool = True
    
    def add_tag(self, tag: str) -> None:
        if tag not in self.tags:
            self.tags.append(tag)
    
    def set_metadata(self, key: str, value: Any) -> None:
        self.metadata[key] = value
    
    def to_dict(self) -> Dict[str, Any]:
        return {
            'id': self.id,
            'name': self.name,
            'price': self.price,
            'category': self.category,
            'tags': self.tags,
            'metadata': self.metadata,
            'is_active': self.is_active
        }
    
    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> 'Product':
        product = cls(data['name'], data['price'], data['category'])
        product.id = data.get('id', product.id)
        product.tags = data.get('tags', [])
        product.metadata = data.get('metadata', {})
        product.is_active = data.get('is_active', True)
        return product

class ProductManager:
    """Manager for products with type safety."""
    
    def __init__(self):
        self.products: Dict[int, Product] = {}
        self.categories: Set[str] = set()
    
    def add_product(self, product: Product) -> None:
        self.products[product.id] = product
        self.categories.add(product.category)
    
    def get_product(self, product_id: int) -> Optional[Product]:
        return self.products.get(product_id)
    
    def get_products_by_category(self, category: str) -> List[Product]:
        return [
            product for product in self.products.values()
            if product.category == category and product.is_active
        ]
    
    def save_to_file(self, filename: str) -> None:
        data = {
            'products': [product.to_dict() for product in self.products.values()],
            'categories': list(self.categories)
        }
        with open(filename, 'w') as f:
            json.dump(data, f, indent=2)
    
    @classmethod
    def load_from_file(cls, filename: str) -> 'ProductManager':
        manager = cls()
        try:
            with open(filename, 'r') as f:
                data = json.load(f)
            
            for product_data in data.get('products', []):
                product = Product.from_dict(product_data)
                manager.add_product(product)
                
        except FileNotFoundError:
            pass  # Start with empty manager
        
        return manager

# Usage with type checking
manager: ProductManager = ProductManager()
laptop: Product = Product("MacBook Pro", 2499.99, "computers")
laptop.add_tag("apple")
laptop.add_tag("laptop")

manager.add_product(laptop)

python code snippet end

IDE Support and Tooling

python code snippet start

# ide_support_example.py
from typing import TYPE_CHECKING, TypeVar, Generic, overload

# Import for type checking only (not at runtime)
if TYPE_CHECKING:
    from expensive_module import HeavyObject

# Generic type variables
K = TypeVar('K')  # Key type
V = TypeVar('V')  # Value type

class Cache(Generic[K, V]):
    """Generic cache with type annotations for IDE support."""
    
    def __init__(self, max_size: int = 100):
        self._data: Dict[K, V] = {}
        self._access_order: List[K] = []
        self.max_size: int = max_size
    
    def get(self, key: K) -> Optional[V]:
        """Get value by key with type safety."""
        if key in self._data:
            # Move to end (most recently used)
            self._access_order.remove(key)
            self._access_order.append(key)
            return self._data[key]
        return None
    
    def put(self, key: K, value: V) -> None:
        """Put value with automatic eviction."""
        if key in self._data:
            self._access_order.remove(key)
        elif len(self._data) >= self.max_size:
            # Evict least recently used
            lru_key = self._access_order.pop(0)
            del self._data[lru_key]
        
        self._data[key] = value
        self._access_order.append(key)
    
    @overload
    def get_or_default(self, key: K, default: V) -> V: ...
    
    @overload
    def get_or_default(self, key: K, default: None = None) -> Optional[V]: ...
    
    def get_or_default(self, key: K, default: Optional[V] = None) -> Optional[V]:
        """Get value or return default."""
        return self.get(key) or default

# Usage with full IDE support
string_cache: Cache[str, str] = Cache()
int_cache: Cache[int, List[str]] = Cache(max_size=50)

string_cache.put("greeting", "Hello, World!")
greeting: Optional[str] = string_cache.get("greeting")  # IDE knows this is Optional[str]

int_cache.put(1, ["apple", "banana"])
fruits: Optional[List[str]] = int_cache.get(1)  # IDE knows this is Optional[List[str]]

python code snippet end

Best Practices and Patterns

Gradual Typing Strategy

python code snippet start

# gradual_typing.py - Converting legacy code to use annotations
from typing import Any, Dict, List, Optional, Union, cast

# Legacy function without annotations
def process_data_legacy(data, config, options=None):
    """Legacy function without type hints."""
    if options is None:
        options = {}
    
    result = []
    for item in data:
        if item.get('active', True):
            processed = transform_item(item, config)
            result.append(processed)
    
    return result

# Step 1: Add basic annotations
def process_data_basic(
    data: List[Dict[str, Any]], 
    config: Dict[str, Any], 
    options: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
    """Function with basic type annotations."""
    if options is None:
        options = {}
    
    result: List[Dict[str, Any]] = []
    for item in data:
        if item.get('active', True):
            processed = transform_item(item, config)
            result.append(processed)
    
    return result

# Step 2: Refine with more specific types
from typing import TypedDict

class DataItem(TypedDict, total=False):
    id: int
    name: str
    active: bool
    value: float
    metadata: Dict[str, Any]

class ProcessConfig(TypedDict):
    multiplier: float
    format: str
    validation: bool

class ProcessOptions(TypedDict, total=False):
    debug: bool
    timeout: int
    retry_count: int

def process_data_refined(
    data: List[DataItem], 
    config: ProcessConfig, 
    options: Optional[ProcessOptions] = None
) -> List[DataItem]:
    """Function with refined type annotations."""
    if options is None:
        options = {}
    
    result: List[DataItem] = []
    for item in data:
        if item.get('active', True):
            processed = transform_item_typed(item, config)
            result.append(processed)
    
    return result

def transform_item_typed(item: DataItem, config: ProcessConfig) -> DataItem:
    """Transform a single item with type safety."""
    transformed: DataItem = item.copy()
    
    if 'value' in item:
        transformed['value'] = item['value'] * config['multiplier']
    
    return transformed

python code snippet end

Forward References and Circular Dependencies

python code snippet start

# forward_references.py
from __future__ import annotations  # Enable string annotations
from typing import Optional, List, TYPE_CHECKING

if TYPE_CHECKING:
    from .related_module import RelatedClass

class Node:
    """Tree node with forward reference to itself."""
    
    def __init__(self, value: int, parent: Optional[Node] = None):
        self.value: int = value
        self.parent: Optional[Node] = parent
        self.children: List[Node] = []
        self.related: Optional[RelatedClass] = None
    
    def add_child(self, child: Node) -> None:
        child.parent = self
        self.children.append(child)
    
    def get_ancestors(self) -> List[Node]:
        ancestors: List[Node] = []
        current = self.parent
        while current:
            ancestors.append(current)
            current = current.parent
        return ancestors
    
    def find_by_value(self, value: int) -> Optional[Node]:
        if self.value == value:
            return self
        
        for child in self.children:
            result = child.find_by_value(value)
            if result:
                return result
        
        return None

class Tree:
    """Tree structure with proper type annotations."""
    
    def __init__(self, root_value: int):
        self.root: Node = Node(root_value)
        self.node_count: int = 1
    
    def insert(self, value: int, parent_value: int) -> bool:
        parent = self.root.find_by_value(parent_value)
        if parent:
            new_node = Node(value)
            parent.add_child(new_node)
            self.node_count += 1
            return True
        return False
    
    def traverse_dfs(self) -> List[int]:
        def dfs(node: Node) -> List[int]:
            result = [node.value]
            for child in node.children:
                result.extend(dfs(child))
            return result
        
        return dfs(self.root)

python code snippet end

Error Handling with Annotations

python code snippet start

# error_handling.py
from typing import Union, TypeVar, Generic, Callable, Any
from dataclasses import dataclass

T = TypeVar('T')
E = TypeVar('E', bound=Exception)

@dataclass
class Result(Generic[T, E]):
    """Result type for error handling without exceptions."""
    
    def __init__(self, value: T = None, error: E = None):
        self._value = value
        self._error = error
    
    @property
    def is_success(self) -> bool:
        return self._error is None
    
    @property
    def is_error(self) -> bool:
        return self._error is not None
    
    def unwrap(self) -> T:
        if self._error:
            raise self._error
        return self._value
    
    def unwrap_or(self, default: T) -> T:
        return self._value if self.is_success else default
    
    def map(self, func: Callable[[T], Any]) -> Result[Any, E]:
        if self.is_success:
            try:
                return Result(func(self._value))
            except Exception as e:
                return Result(error=e)
        return Result(error=self._error)

def safe_divide(a: float, b: float) -> Result[float, ValueError]:
    """Safe division with error handling."""
    if b == 0:
        return Result(error=ValueError("Division by zero"))
    return Result(a / b)

def safe_parse_int(value: str) -> Result[int, ValueError]:
    """Safe integer parsing."""
    try:
        return Result(int(value))
    except ValueError as e:
        return Result(error=e)

# Usage with type safety
result1: Result[float, ValueError] = safe_divide(10, 2)
if result1.is_success:
    print(f"Division result: {result1.unwrap()}")

result2: Result[int, ValueError] = safe_parse_int("abc")
parsed_value: int = result2.unwrap_or(0)  # Safe with default

python code snippet end

Variable annotations in PEP 526 bridge the gap between documentation and type safety, enabling better tooling support while maintaining Python’s dynamic nature and runtime flexibility.

This PEP builds directly on PEP 484's function annotations , extending type hints to all variables for comprehensive static analysis.

Reference: PEP 526 - Variable Annotations