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.