Python - Type Hints / Annotations

• Type hints in Python are optional annotations that specify expected types for variables, function parameters, and return values—they don't enforce runtime type checking but enable static analysis...

Key Insights

• Type hints in Python are optional annotations that specify expected types for variables, function parameters, and return values—they don’t enforce runtime type checking but enable static analysis tools like mypy to catch type errors before execution. • Generic types, Protocol classes, and TypedDict provide sophisticated type annotation capabilities for collections, structural subtyping, and dictionary schemas without requiring inheritance or runtime overhead. • Strategic use of Union types, Optional, and type narrowing with isinstance() checks creates self-documenting code that prevents common bugs while maintaining Python’s dynamic flexibility.

Basic Type Annotations

Type hints use the colon syntax for variables and arrow notation for return types. They appear in function signatures, variable declarations, and class attributes.

def calculate_total(price: float, quantity: int) -> float:
    return price * quantity

# Variable annotations
name: str = "Product A"
count: int = 100
is_active: bool = True

# Type hints without immediate assignment
inventory: dict[str, int]
inventory = {"apples": 50, "oranges": 30}

For Python 3.9+, use built-in collection types directly. Earlier versions require importing from typing:

# Python 3.9+
def process_items(items: list[str]) -> dict[str, int]:
    return {item: len(item) for item in items}

# Python 3.7-3.8
from typing import List, Dict

def process_items(items: List[str]) -> Dict[str, int]:
    return {item: len(item) for item in items}

Optional and Union Types

Optional[X] indicates a value can be type X or None. It’s equivalent to Union[X, None]. Python 3.10+ supports the pipe operator for unions.

from typing import Optional, Union

def find_user(user_id: int) -> Optional[dict]:
    # Returns dict or None if not found
    users = {1: {"name": "Alice"}, 2: {"name": "Bob"}}
    return users.get(user_id)

# Union types for multiple possibilities
def process_input(value: Union[int, str, float]) -> str:
    return str(value)

# Python 3.10+ syntax
def process_input(value: int | str | float) -> str:
    return str(value)

# Type narrowing with isinstance
def handle_response(response: int | dict) -> str:
    if isinstance(response, dict):
        # Type checker knows response is dict here
        return response.get("message", "")
    else:
        # Type checker knows response is int here
        return f"Status code: {response}"

Generic Types and Type Variables

Type variables create reusable generic functions that preserve type information through the call chain.

from typing import TypeVar, Sequence, Callable

T = TypeVar('T')

def first_element(items: Sequence[T]) -> T | None:
    return items[0] if items else None

# Type checker infers return type based on input
result1: int | None = first_element([1, 2, 3])  # int | None
result2: str | None = first_element(["a", "b"])  # str | None

# Constrained type variables
Number = TypeVar('Number', int, float)

def add(a: Number, b: Number) -> Number:
    return a + b  # type: ignore

# Generic class
from typing import Generic

class Container(Generic[T]):
    def __init__(self, value: T) -> None:
        self.value = value
    
    def get(self) -> T:
        return self.value

int_container: Container[int] = Container(42)
str_container: Container[str] = Container("hello")

Callable Types

Annotate functions as parameters using Callable with parameter types and return type.

from typing import Callable

def apply_operation(
    value: int,
    operation: Callable[[int], int]
) -> int:
    return operation(value)

def double(x: int) -> int:
    return x * 2

result = apply_operation(5, double)  # Returns 10

# Multiple parameters
def apply_binary(
    a: int,
    b: int,
    func: Callable[[int, int], int]
) -> int:
    return func(a, b)

# Callback with no return value
def process_data(
    data: list[str],
    callback: Callable[[str], None]
) -> None:
    for item in data:
        callback(item)

Protocol Classes for Structural Subtyping

Protocols define interfaces based on structure rather than inheritance—duck typing with type safety.

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> str:
        ...

class Circle:
    def draw(self) -> str:
        return "Drawing circle"

class Square:
    def draw(self) -> str:
        return "Drawing square"

def render(shape: Drawable) -> None:
    print(shape.draw())

# Both work without inheriting from Drawable
render(Circle())
render(Square())

# Runtime checkable protocols
from typing import runtime_checkable

@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None:
        ...

class FileHandler:
    def close(self) -> None:
        print("Closing file")

handler = FileHandler()
if isinstance(handler, Closeable):
    handler.close()

TypedDict for Dictionary Schemas

TypedDict creates typed dictionaries with specific key-value pairs, useful for configuration objects and API responses.

from typing import TypedDict, NotRequired

class UserDict(TypedDict):
    id: int
    name: str
    email: str

def create_user(user: UserDict) -> None:
    print(f"Creating user: {user['name']}")

# Type checker validates structure
user: UserDict = {
    "id": 1,
    "name": "Alice",
    "email": "alice@example.com"
}

create_user(user)

# Optional fields (Python 3.11+)
class ProductDict(TypedDict):
    name: str
    price: float
    description: NotRequired[str]  # Optional field

# Total=False makes all fields optional
class PartialConfig(TypedDict, total=False):
    debug: bool
    timeout: int
    retries: int

Literal Types and Final

Literal types restrict values to specific constants. Final prevents reassignment.

from typing import Literal, Final

def set_mode(mode: Literal["read", "write", "append"]) -> None:
    print(f"Mode set to: {mode}")

set_mode("read")  # OK
# set_mode("delete")  # Type error

# Literal with multiple types
def process(status: Literal[200, 404, 500]) -> str:
    return f"HTTP {status}"

# Final constants
MAX_CONNECTIONS: Final = 100
API_VERSION: Final[str] = "v2"

# MAX_CONNECTIONS = 200  # Type error: cannot assign to Final

Type Aliases and NewType

Type aliases create readable names for complex types. NewType creates distinct types for type safety.

from typing import NewType, TypeAlias

# Type alias
UserId = int
UserName = str

def get_user(user_id: UserId) -> UserName:
    return f"User_{user_id}"

# Still just an int - no runtime distinction
id1: UserId = 123
id2: int = 123
# id1 and id2 are interchangeable

# NewType creates distinct type
UserId = NewType('UserId', int)
ProductId = NewType('ProductId', int)

def fetch_user(user_id: UserId) -> dict:
    return {"id": user_id, "name": "Alice"}

user_id = UserId(42)
product_id = ProductId(42)

fetch_user(user_id)  # OK
# fetch_user(product_id)  # Type error
# fetch_user(42)  # Type error

# Complex type aliases (Python 3.10+)
type JsonValue = str | int | float | bool | None | list['JsonValue'] | dict[str, 'JsonValue']

# Python 3.9 and earlier
JsonValue: TypeAlias = Union[
    str, int, float, bool, None,
    list['JsonValue'],
    dict[str, 'JsonValue']
]

Practical Example: Type-Safe API Client

from typing import Protocol, TypedDict, Literal, Generic, TypeVar
from dataclasses import dataclass

class APIResponse(TypedDict):
    status: Literal["success", "error"]
    data: dict | None
    message: str

T = TypeVar('T')

@dataclass
class Result(Generic[T]):
    value: T | None
    error: str | None
    
    @property
    def is_success(self) -> bool:
        return self.error is None

class HTTPClient(Protocol):
    def get(self, url: str) -> APIResponse:
        ...

class APIClient:
    def __init__(self, client: HTTPClient) -> None:
        self.client = client
    
    def fetch_user(self, user_id: int) -> Result[dict]:
        response = self.client.get(f"/users/{user_id}")
        
        if response["status"] == "success" and response["data"]:
            return Result(value=response["data"], error=None)
        return Result(value=None, error=response["message"])

Type hints transform Python into a language that catches errors during development while preserving runtime flexibility. Use mypy or pyright to validate types in your CI pipeline.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.