Instroduction
This is a little book about Python type hints, generated by Claude AI Sonnect 3.5 with my custom prompt.
Materials may contain errors.
Official documentation: Static Typing with Python
Topics are organized base on python-type-challenges
any
Core Concept:
The Any type is part of Python's typing system and represents a type that can be literally anything. It's imported from the typing module and is essentially an escape hatch in the type system.
from typing import Any
Let's break this down with detailed examples and use cases:
1. Basic Usage
from typing import Any
# Variable can hold any type
x: Any = 1 # Valid
x = "hello" # Valid
x = [1, 2, 3] # Valid
x = None # Valid
# Function accepting and returning any type
def process_data(data: Any) -> Any:
return data
2. Key Characteristics
Here's a visual representation of how Any relates to other types:
Any
|
+-----------+-----------+
| | |
Numbers Strings Objects
| | |
(int, float) str (custom classes)
3. Common Use Cases
Let's explore when to use Any with concrete examples:
from typing import Any, List, Dict
# 1. Mixed-type collections
mixed_list: List[Any] = [1, "hello", True, 3.14]
# 2. Unknown external data
def parse_json_response(response: Any) -> Dict[str, Any]:
return response.json()
# 3. Dynamic attribute access
class DynamicClass:
def __getattr__(self, name: str) -> Any:
return self.__dict__.get(name)
4. Comparison with Other Types
Let's see how Any differs from similar concepts:
flowchart LR
A[Any] --> B[No type checking]
A --> C[Compatible with all types]
D[object] --> E[Base class of all types]
D --> F[Type checking enforced]
G[Union types] --> H[Limited set of types]
G --> I[Type checking for specified types]
style A fill:#f9f,stroke:#333,stroke-width:4px
5. Best Practices and Warnings
from typing import Any, List, Optional
# ❌ Avoid excessive use of Any
def bad_practice(data: Any) -> Any:
return data.something() # No type checking!
# ✅ Better: Use more specific types when possible
def good_practice(data: Optional[Dict[str, str]]) -> List[str]:
if data is None:
return []
return list(data.values())
6. Advanced Usage Patterns
from typing import Any, TypeVar, Generic
T = TypeVar('T')
# Using Any in generic classes
class Container(Generic[T]):
def __init__(self, item: T) -> None:
self.item = item
def get_item(self) -> T:
return self.item
# Any vs explicit type variable
container_any: Container[Any] = Container(42)
container_int: Container[int] = Container(42)
7. Common Gotchas
from typing import Any, List
# Gotcha 1: Any vs Object
def process_list(items: List[Any]) -> None: # Accepts list of anything
for item in items:
print(item)
def process_objects(items: List[object]) -> None: # Technically more restrictive
for item in items:
print(item)
# Gotcha 2: Type inference with Any
x: Any = 1
reveal_type(x) # Type is Any, not int
y = 1 # Better: Let Python infer the type
reveal_type(y) # Type is int
8. When to Use Any
✅ Good use cases:
- Working with dynamic data (JSON, YAML)
- Interfacing with external APIs
- Implementing dynamic behaviors
- Migration of legacy code
❌ Avoid using Any when:
- You know the specific type
- Type checking is crucial for correctness
- Working with core business logic
- Building public APIs
Real-world Example:
from typing import Any, Dict, TypeVar, Type
T = TypeVar('T')
class DataParser:
def parse_unknown_data(self, data: Any) -> Dict[str, Any]:
"""Parse data of unknown structure."""
if isinstance(data, dict):
return data
elif isinstance(data, str):
return {"value": data}
return {"data": str(data)}
def parse_known_data(self, data: Any, target_type: Type[T]) -> T:
"""Parse data into a known type."""
if isinstance(data, target_type):
return data
raise TypeError(f"Cannot convert {type(data)} to {target_type}")
Key Takeaways:
Anyis a special type hint that represents complete type flexibility- Use it sparingly and only when more specific types aren't practical
- It's useful for gradual typing and working with dynamic data
- Consider it a last resort in type hinting
- Always prefer more specific types when possible
dict
Understanding Dict Type Hints
Let's explore Python's dictionary type hints through a visual progression from basic to advanced usage.
flowchart LR
A[Dict Type Hints] --> B[Basic Dict]
A --> C[Nested Dict]
A --> D[Union Types]
A --> E[TypedDict]
B --> B1["Dict[str, int]"]
B --> B2["Dict[str, Any]"]
C --> C1["Dict[str, Dict[str, int]]"]
D --> D1["Dict[str, Union[int, str]]"]
D --> D2["Dict[Union[str, int], str]"]
E --> E1["Class-based TypedDict"]
E --> E2["dict-syntax TypedDict"]
1. Basic Dictionary Type Hints
from typing import Dict
# Basic dictionary with string keys and integer values
scores: Dict[str, int] = {"Alice": 95, "Bob": 87}
# Dictionary with Any type values
from typing import Any
flexible_dict: Dict[str, Any] = {
"name": "John",
"age": 30,
"active": True
}
2. Nested Dictionaries
# Nested dictionary type hint
user_data: Dict[str, Dict[str, str]] = {
"user1": {"name": "Alice", "email": "alice@example.com"},
"user2": {"name": "Bob", "email": "bob@example.com"}
}
3. Union Types in Dictionaries
from typing import Union
# Dictionary with multiple possible value types
mixed_values: Dict[str, Union[int, str]] = {
"age": 25,
"name": "Alice",
"id": "A123" # Can be either string or int
}
# Dictionary with multiple possible key types
flexible_keys: Dict[Union[str, int], str] = {
"name": "Alice",
1: "first place",
"email": "alice@example.com"
}
4. TypedDict (Advanced Usage)
from typing import TypedDict
# Class-based syntax
class UserProfile(TypedDict):
name: str
age: int
email: str
is_active: bool
# Usage
user: UserProfile = {
"name": "Alice",
"age": 30,
"email": "alice@example.com",
"is_active": True
}
# Dict-syntax (alternative way)
from typing_extensions import TypedDict
User = TypedDict('User', {
'name': str,
'age': int,
'email': str
})
5. Optional Keys in TypedDict
total=False means all keys are optional
class OptionalUserProfile(TypedDict, total=False):
name: str
age: int
email: str
phone: str
Key Insights and Best Practices:
-
Type Specificity
- Be as specific as possible with types
- Use
Anyonly when absolutely necessary
-
TypedDict vs Regular Dict
- Use
TypedDictwhen you need to enforce specific key-value structure - Use regular
Dictfor more flexible dictionary structures
- Use
-
Performance Considerations
- Type hints are removed at runtime
- No performance impact during execution
- Helpful for development and static type checking
Common Pitfalls to Avoid:
# ❌ Don't do this (too broad)
data: Dict = {"key": "value"} # Missing type parameters
# ✅ Do this instead
data: Dict[str, str] = {"key": "value"}
# ❌ Don't use Any unless necessary
flexible: Dict[str, Any] = {"name": "John"}
# ✅ Be specific when possible
user_data: Dict[str, Union[str, int, bool]] = {
"name": "John",
"age": 30,
"active": True
}
Advanced Tips:
- Using with Protocol for Duck Typing
from typing import Protocol, Dict
class Serializable(Protocol):
def to_dict(self) -> Dict[str, Any]: ...
def save_data(obj: Serializable) -> None:
data = obj.to_dict()
# Save data...
- Generic Dictionaries
from typing import TypeVar, Dict
K = TypeVar('K')
V = TypeVar('V')
def get_first_item(d: Dict[K, V]) -> V:
return next(iter(d.values()))
final
1. Basic Understanding of Final
The Final type hint, introduced in Python 3.8 (PEP 591), indicates that a variable or attribute should only be assigned once and not be reassigned after its initialization. It's similar to the final keyword in languages like Java.
2. Core Concepts
Let's look at the different ways to use Final:
from typing import Final
# Simple Final variable
COUNT: Final = 100
# Final with type specification
MAX_SIZE: Final[int] = 1000
# Final in class attributes
class Config:
DEBUG: Final[bool] = False
def __init__(self):
self.API_KEY: Final[str] = "abc123" # Instance attribute
3. Visual Representation of Final Assignment Flow
Initial Assignment Reassignment Attempt
COUNT: Final = 100 COUNT = 200
| |
v v
[Memory Location] →→→ [Type Checker Error!]
"100" "Cannot assign to final name"
4. Advanced Usage Patterns
Let's explore more complex scenarios:
from typing import Final, List, Dict
# With collections
ALLOWED_STATES: Final[List[str]] = ["PENDING", "ACTIVE", "CLOSED"]
# With nested structures
CONFIG: Final[Dict[str, int]] = {
"timeout": 30,
"retries": 3
}
# With class type annotations
class User:
DEFAULT_ROLE: Final[str] = "guest"
def __init__(self, name: str):
self.name: Final[str] = name
5. Key Insights and Behavior
- Content Mutability
# The container is final, but content can be mutable
ITEMS: Final[List[int]] = [1, 2, 3]
ITEMS.append(4) # This is allowed!
ITEMS = [4, 5, 6] # This raises an error
- Type Inheritance
class Parent:
x: Final[int] = 1
class Child(Parent):
x = 2 # Error: cannot override final attribute
6. Common Use Cases
- Constants Definition
class DatabaseConfig:
MAX_CONNECTIONS: Final[int] = 100
TIMEOUT: Final[float] = 30.0
- Immutable Configuration
class APISettings:
BASE_URL: Final[str] = "https://api.example.com"
API_VERSION: Final[int] = 1
7. Best Practices and Pitfalls
Let's visualize the difference between Final variable and Final content:
Final Variable (Immutable Reference)
╔════════════╗
CONST: Final[list] = [1,2,3] ═══════► ║ [1,2,3] ║
╚════════════╝
▲
│ (can modify content)
append(4)
│
[1,2,3,4]
Final Content (Immutable Content)
╔═════════════╗
from typing import FrozenSet ║ ║
CONST: Final[FrozenSet] = frozenset() ║ {1,2,3} ║
║ (immutable) ║
╚═════════════╝
Let me create a diagram showing the type checking process for Final variables:
8. Important Notes:
-
Type Checker Only
Finalis enforced by type checkers (like mypy)- No runtime enforcement by Python itself
-
Initialization Requirements
- Must be initialized immediately or in
__init__ - Cannot be assigned in other methods
- Must be initialized immediately or in
-
Inheritance Behavior
- Cannot override Final attributes in subclasses
- Can inherit Final attributes from parent classes
When to Use Final:
- When defining true constants that should never change
- For configuration values that should be immutable
- To prevent accidental override of important attributes
- To make code intentions clear to other developers
kwargs
Two Correct Ways to Type kwargs
1. Value Type Annotation
def foo(**kwargs: int | str):
...
This is the standard way to type kwargs when you want to specify what types the values can be. The **kwargs syntax automatically handles the dictionary structure, and you only need to specify the types of values.
Example usage:
foo(a=1, b="hello") # Valid
foo(x=1.0) # Invalid - float not allowed
2. TypedDict with Unpack (Python 3.12+)
from typing import TypedDict, Unpack
class Movie(TypedDict):
name: str
year: int
def foo(**kwargs: Unpack[Movie]):
...
Use this when you need to specify exact structure of the kwargs with specific field names and their types.
Example usage:
foo(name="Life of Brian", year=1979) # Valid
foo(name="Life of Brian") # Invalid - missing year
foo(name="Life of Brian", year="1979") # Invalid - year must be int
Mark all keywords as optional with total=False
class Kw(TypedDict, total=False):
key1: int
key2: str
Mark specific keywords as optional with typing.NotRequired
class Kw(TypedDict):
key1: int
key2: NotRequired[str]
Common Mistake to Avoid
# INCORRECT:
def foo(**kwargs: dict[str, int | str]):
...
This is wrong because:
- It tries to make each value a dictionary
- The
**syntax already provides the dictionary structure - Will cause type checker errors when used
Test Results Example
def foo1(**kwargs: dict[str, int | str]): # Wrong
...
def foo2(**kwargs: int | str): # Correct
...
def foo3(**kwargs: Unpack[Movie]): # Correct
...
# Test calls
foo1(**{"name": "Life of Brian", "year": 1979}) # Error
foo2(**{"name": "Life of Brian", "year": 1979}) # OK
foo3(**{"name": "Life of Brian", "year": 1979}) # OK
list
Basic List Type Hints
The fundamental way to declare a list type hint in Python uses the list type with square brackets to specify the type of elements:
# Basic list of integers
numbers: list[int] = [1, 2, 3, 4, 5]
# Basic list of strings
names: list[str] = ["Alice", "Bob", "Charlie"]
Type Hint Evolution
Let me show you how list type hints have evolved:
# Python 3.5+ (deprecated)
from typing import List
numbers: List[int] = [1, 2, 3]
# Python 3.9+ (modern approach)
numbers: list[int] = [1, 2, 3] # Built-in list type
Complex List Types
Lists can contain more complex types. Let's explore different scenarios:
# Nested lists
matrix: list[list[int]] = [[1, 2], [3, 4]]
# List of optional values
from typing import Optional
nullable_numbers: list[Optional[int]] = [1, None, 3, None, 5]
# List of mixed types using Union
from typing import Union
mixed_list: list[Union[int, str]] = [1, "two", 3, "four"]
# List of any type
from typing import Any
flexible_list: list[Any] = [1, "two", True, 3.14]
Let's visualize how these type hints work in memory:
List[int]
|
.───────────────────────────.
│ │
[1] [2] [3] [4] [5]
│ │ │ │ │
int int int int int
List[List[int]]
|
.───────────────.
│ │
[1, 2] [3, 4]
│ │ │ │
int int int int
Function Signatures with List Types
Here's how to use list type hints in functions:
def process_numbers(numbers: list[int]) -> list[int]:
"""Process a list of integers and return a new list."""
return [num * 2 for num in numbers]
def append_to_list(items: list[str], item: str) -> None:
"""Append an item to a list (modifies in place)."""
items.append(item)
Let's visualize the function flow:
flowchart LR
Input("Input List[int]") --> TypeCheck1["Type Checking"]
TypeCheck1 --> Function["process_numbers()"]
Function --> TypeCheck2["Type Checking"]
TypeCheck2 --> Output("Output List[int]")
TypeCheck1 -.- Note1["Verifies input types<br>match function signature"]
TypeCheck2 -.- Note2["Ensures return value<br>matches declared type"]
Advanced Concepts
- Covariance and Contravariance
# Covariant type variable
from typing import TypeVar
T_co = TypeVar('T_co', covariant=True)
class ReadOnlyList(Generic[T_co]):
def __init__(self, items: list[T_co]) -> None:
self._items = items
- Protocol Support
from typing import Protocol
class Sizeable(Protocol):
def size(self) -> int: ...
def process_sized_items(items: list[Sizeable]) -> int:
return sum(item.size() for item in items)
Best Practices and Common Pitfalls
-
Do's:
- Always specify the type of elements in the list
- Use the built-in
listtype for Python 3.9+ - Consider using more specific types when possible
-
Don'ts:
- Don't use
Listfrom typing module in newer Python versions - Avoid using
list[Any]unless absolutely necessary - Don't confuse runtime type checking with static type hints
- Don't use
Type Checking in Action
Here's how a type checker like mypy would analyze your code:
# mypy will approve this
numbers: list[int] = [1, 2, 3]
numbers.append(4) # OK
# mypy will flag this as an error
numbers.append("5") # Error: Argument 1 to "append" of "list" has incompatible type "str"; expected "int"
Advanced Real-World Example
Here's a more complex example showing how list type hints work in a practical scenario:
from typing import TypeVar, Callable
T = TypeVar('T')
U = TypeVar('U')
def transform_list(items: list[T], transformer: Callable[[T], U]) -> list[U]:
"""
Transform a list of items of type T into a list of type U
using the provided transformer function.
"""
return [transformer(item) for item in items]
# Usage
numbers: list[int] = [1, 2, 3]
strings: list[str] = transform_list(numbers, str)
optional
1. Using the Optional Type
The most common way to express optional types is using Optional from the typing module. Here's a comprehensive explanation:
from typing import Optional
def greet(name: Optional[str] = None) -> str:
if name is None:
return "Hello, Guest!"
return f"Hello, {name}!"
2. Union with None
Another equivalent approach is using Union with None:
from typing import Union
def process_data(data: Union[list, None] = None) -> list:
if data is None:
return []
return data
Let me create a visual representation to show how these type hints work in the context of function calls:
flowchart TD
A[Function Call] --> B{Argument Provided?}
B -->|Yes| C{Type Check}
B -->|No| D[Use Default Value]
C -->|Match| E[Execute Function]
C -->|No Match| F[Type Error]
D --> E
style E fill:#90EE90
style F fill:#FFB6C1
3. Different Ways to Express Optional Types
Here are the main approaches with their specific use cases:
from typing import Optional, Union, Any
# Method 1: Using Optional
def func1(arg: Optional[int] = None) -> int:
return arg if arg is not None else 0
# Method 2: Using Union with None
def func2(arg: Union[str, None] = None) -> str:
return arg if arg is not None else ""
# Method 3: Using default values with type
def func3(arg: int = 0) -> int:
return arg
# Method 4: Using Any for maximum flexibility
def func4(arg: Any = None) -> Any:
return arg if arg is not None else None
4. Key Insights and Best Practices
-
OptionalvsUnionOptional[T]is equivalent toUnion[T, None]Optionalis more readable and explicitly indicates optionality
-
Default Values
# Good: Type matches default value def process_list(items: Optional[list] = None) -> list: return items or [] # Better: More specific about contained type def process_numbers(items: Optional[list[int]] = None) -> list[int]: return items or [] -
Type Checking Behavior
from typing import Optional # This allows both None and int def func(x: Optional[int]) -> str: return str(x if x is not None else 0) # These are all valid func(None) # Valid func(42) # Valid func() # Error: missing required argument
5. Common Patterns and Use Cases
from typing import Optional, TypeVar
T = TypeVar('T')
# Generic optional parameter
def get_or_default(value: Optional[T], default: T) -> T:
return value if value is not None else default
# Multiple optional parameters
def configure(host: Optional[str] = None,
port: Optional[int] = None,
timeout: Optional[float] = None) -> dict:
config = {}
if host is not None:
config['host'] = host
if port is not None:
config['port'] = port
if timeout is not None:
config['timeout'] = timeout
return config
6. Type Checking and IDE Support
Modern IDEs like PyCharm or VSCode with Pylance will provide:
- Warning for incorrect types
- Autocompletion support
- Documentation hints
Best Practices Summary:
- Use
Optionalwhen a parameter can beNone - Match default values with their type annotations
- Be explicit about container types (e.g.,
list[int]instead of justlist) - Use
TypeVarfor generic type annotations - Consider using more specific types instead of
Anywhen possible
return
Basic Return Type Hints
Let's start with the fundamental syntax and gradually move to more complex scenarios.
def get_name() -> str:
return "Alice"
def calculate_age(birth_year: int) -> int:
return 2024 - birth_year
Visual Representation of Type Hint Flow
+----------------+
Input Parameters | Function | Return Type
→ | | →
(birth_year: int) | calculate_age | -> int
| |
+----------------+
Let's explore different return type scenarios:
1. Basic Types
# Simple types
def get_count() -> int:
return 42
def is_valid() -> bool:
return True
def get_price() -> float:
return 19.99
2. Optional Returns
from typing import Optional
def find_user(id: int) -> Optional[str]:
if id > 0:
return "User123"
return None
3. Multiple Return Types (Union)
from typing import Union
def process_value(val: int) -> Union[str, int]:
if val > 100:
return "Too large"
return val * 2
4. Collections
from typing import List, Dict, Set, Tuple
def get_users() -> List[str]:
return ["Alice", "Bob", "Charlie"]
def get_scores() -> Dict[str, int]:
return {"Alice": 95, "Bob": 87}
def get_coordinates() -> Tuple[int, int]:
return (10, 20)
5. Complex Types
Let's visualize a more complex type hierarchy:
flowchart TD
A[Function Input] --> B[Process]
B -->|Case 1| C["Dict[str, List[int]]"]
B -->|Case 2| D[None]
C --> E[Return Value]
D --> E
from typing import Dict, List, Optional
def get_user_scores() -> Optional[Dict[str, List[int]]]:
return {
"Alice": [95, 87, 91],
"Bob": [88, 92, 85]
}
6. Generator Returns
from typing import Generator, Iterator
def count_up() -> Generator[int, None, None]:
i = 0
while True:
yield i
i += 1
7. Callable Returns
from typing import Callable
def get_operation() -> Callable[[int, int], int]:
def add(x: int, y: int) -> int:
return x + y
return add
Best Practices and Tips:
-
Type Consistency
# Good def get_value() -> Optional[str]: if condition: return "value" return None # Avoid def get_value() -> str: if condition: return "value" return None # Type mismatch! -
Documentation Benefits
def process_data() -> Dict[str, List[int]]: """ Returns a dictionary mapping usernames to their score history. Returns: Dict[str, List[int]]: User score mapping """ return {"user": [1, 2, 3]} -
Type Checking
- Use tools like mypy to validate type hints:
mypy your_script.py
Common Pitfalls:
-
Forward References
class Tree: # Use string for self-reference # when you need to reference a type before it's defined def get_child(self) -> 'Tree': return Tree() -
Dynamic Types
from typing import Any def dynamic_function() -> Any: # Use when return type is truly dynamic return some_dynamic_value
tuple
Let me create a visual representation to show the key concepts of tuple type hints:
flowchart TD
A[Tuple Type Hints] --> B[Fixed Length]
A --> C[Variable Length]
A --> D[Named Tuples]
B --> B1[Homogeneous]
B --> B2[Mixed Types]
B1 --> B1A["Tuple[int, int, int]"]
B2 --> B2A["Tuple[str, int, bool]"]
C --> C1["Tuple[int, ...]"]
D --> D1[TypedDict]
D --> D2[NamedTuple Class]
Let's break down tuple type hints in detail:
1. Basic Tuple Type Hints
# Fixed-length tuple with specific types
coordinates: tuple[float, float] = (23.5, 45.1)
# Mixed types in a tuple
user_data: tuple[str, int, bool] = ("Alice", 25, True)
# Empty tuple
empty: tuple[()] = ()
2. Homogeneous Tuples (Same Type)
# Tuple of three integers
triple: tuple[int, int, int] = (1, 2, 3)
# More concise way for homogeneous tuples
# Available in Python 3.9+
scores: tuple[int, ...] = (95, 87, 91, 88)
3. Variable-Length Tuples
# Variable number of strings
names: tuple[str, ...] = ("Alice", "Bob", "Charlie")
# Variable number of mixed types not possible with ...
# This is NOT valid:
# mixed: tuple[str | int, ...] = ("a", 1, "b", 2)
4. Type Hints with Named Tuples
from typing import NamedTuple
class Point(NamedTuple):
x: float
y: float
label: str = "unknown" # With default value
# Usage
point: Point = Point(23.5, 45.1, "center")
5. Common Use Cases and Examples
from typing import Tuple, NamedTuple
# Function return types
def get_user_stats() -> tuple[str, int, float]:
return ("Alice", 25, 67.5)
# Tuple as a parameter type
def plot_point(coord: tuple[float, float]) -> None:
x, y = coord
# Plot the point...
# Named tuple for better readability
class UserStats(NamedTuple):
name: str
age: int
weight: float
def get_user_stats_named() -> UserStats:
return UserStats("Alice", 25, 67.5)
6. Key Insights and Best Practices
-
Type Safety Benefits:
- Catch type-related errors at development time
- Better IDE support and code completion
- Clearer code documentation
-
When to Use Each Pattern:
- Use simple tuples for short, obvious groupings
- Use NamedTuple for complex data structures
- Use variable-length tuples when dealing with homogeneous collections
-
Performance Considerations:
- Tuples are immutable, making them thread-safe
- Type hints don't affect runtime performance
- NamedTuples have minimal overhead compared to regular tuples
7. Common Pitfalls to Avoid
# DON'T: Unclear tuple structure
def process_data(data: tuple) -> tuple: # Too vague!
return data
# DO: Clear type hints
def process_data(data: tuple[str, int]) -> tuple[str, float]:
name, age = data
return (name, float(age) * 1.5)
# DON'T: Using tuple for complex data structures
complex_data: tuple[str, int, str, float, bool] = ("Alice", 25, "NY", 67.5, True)
# DO: Use NamedTuple instead
class UserProfile(NamedTuple):
name: str
age: int
location: str
weight: float
active: bool
typealias
Type aliases in Python are a powerful feature for making type hints more readable and maintainable.
1. Basic Concept Type aliases allow you to create custom names for complex types. They're particularly useful when you have:
- Long or complex type definitions
- Frequently reused types
- Types that might change in the future
Let's break this down with progressive examples:
2. Simple Type Aliases
from typing import List, Dict
# Instead of writing this everywhere:
def process_numbers(numbers: List[int]) -> List[int]:
pass
# Create a type alias
Numbers = List[int]
# Now you can write this:
def process_numbers(numbers: Numbers) -> Numbers:
pass
3. Complex Type Aliases
from typing import Dict, List, Tuple, Union
# Complex type without alias
def process_data(data: Dict[str, List[Tuple[int, Union[str, float]]]]) -> None:
pass
# With type alias
ResponseData = Dict[str, List[Tuple[int, Union[str, float]]]]
# Much clearer!
def process_data(data: ResponseData) -> None:
pass
4. Type Aliases with Generic Types Here's how you can create generic type aliases using TypeVar:
from typing import TypeVar, List, Dict
T = TypeVar('T')
Container = List[T] # Generic container
def first_element(container: Container[T]) -> T:
return container[0]
# Usage
numbers: Container[int] = [1, 2, 3]
strings: Container[str] = ["a", "b", "c"]
5. Nested Type Aliases You can build complex types by combining aliases:
from typing import Dict, List, Union
# Basic aliases
UserId = int
Username = str
Score = float
# Combined aliases
UserData = Dict[UserId, Username]
ScoreData = Dict[UserId, Score]
# Complex alias using others
GameRecord = Dict[UserId, List[Score]]
def update_scores(user: UserId, scores: ScoreData) -> GameRecord:
pass
6. Type Aliases with New Type
Sometimes you want to create a distinct type rather than just an alias. Use NewType:
from typing import NewType
# Regular type alias
PlayerID = int # Just an alias, any int will work
# NewType creates a distinct type
PlayerIDNew = NewType('PlayerIDNew', int)
def get_player(id: PlayerIDNew) -> str:
return f"Player {id}"
# This works with regular alias
regular_id: PlayerID = 123
# This requires explicit conversion with NewType
new_id = PlayerIDNew(123)
7. Modern Python Type Aliases (Python 3.12+)
In newer Python versions, you can use the type keyword:
# Python 3.12+ syntax
type Point = tuple[float, float]
type Points = list[Point]
def calculate_distance(points: Points) -> float:
pass
8. Best Practices
Here's a visual representation of how type aliases can improve code organization:
Without Type Aliases
.───────────────────────────────────────────────────────────────────────.
│ │
│ Dict[str, List[Tuple[int, str]]] Dict[str, List[Tuple[int, str]]] │
│ ↓ ↓ │
│ function_1() function_2() │
│ ↓ ↓ │
│ Dict[str, List[Tuple[int, str]]] Dict[str, List[Tuple[int, str]]] │
'───────────────────────────────────────────────────────────────────────'
With Type Aliases
.────────────────────────────────────────────────────────────────────────.
│ │
│ UserRecord = Dict[str, List[Tuple[int, str]]] │
│ ↓ │
│ UserRecord │
│ ↙ ↘ │
│ function_1() function_2() │
'────────────────────────────────────────────────────────────────────────'
Let's create a sequence diagram showing how type aliases are resolved during type checking:
sequenceDiagram
participant C as Code
participant TC as Type Checker
participant TA as Type Alias
participant BT as Base Types
C->>TC: Use type hint
TC->>TA: Resolve alias
TA->>BT: Resolve to base types
BT->>TC: Return resolved type
TC->>C: Validate type usage
9. Common Use Cases
- API Response Types
from typing import Dict, List, Optional
JSONValue = Union[str, int, float, bool, None, Dict[str, 'JSONValue'], List['JSONValue']]
APIResponse = Dict[str, JSONValue]
- Database Models
from typing import TypedDict
class UserDict(TypedDict):
id: int
name: str
email: str
Users = Dict[int, UserDict]
- Custom Collections
from typing import Dict, List
Matrix = List[List[float]]
Graph = Dict[str, List[str]]
10. Debugging Tips
- Use
reveal_type()in mypy to check what type is being inferred - Keep type aliases close to where they're used
- Document complex type aliases with comments
union
1. Basic Understanding of Union Types
Union types (written as Union[X, Y] or using the | operator in Python 3.10+) allow you to specify that a value could be one of several types. This is particularly useful when a function can accept or return multiple types of values.
2. Syntax and Evolution
# Python 3.7+ (using typing module)
from typing import Union
# Traditional syntax
x: Union[int, str] = "hello"
# Python 3.10+ (using | operator)
y: int | str = 42
3. Visual Representation of Union Types
Let me create a diagram to show how Union types work conceptually:
flowchart TD
V[Variable with Union Type] --> T{Type Check}
T -->|is int| I[Integer Processing]
T -->|is str| S[String Processing]
I --> R[Result]
S --> R
style V fill:#f9f,stroke:#333,stroke-width:2px
style T fill:#bbf,stroke:#333,stroke-width:2px
style I fill:#bfb,stroke:#333,stroke-width:2px
style S fill:#bfb,stroke:#333,stroke-width:2px
style R fill:#fbb,stroke:#333,stroke-width:2px
4. Practical Examples
from typing import Union
# Function accepting either int or float
def double_number(value: Union[int, float]) -> Union[int, float]:
return value * 2
# Function accepting string or list of strings
def process_data(data: Union[str, list[str]]) -> str:
if isinstance(data, list):
return ", ".join(data)
return data.upper()
# Using the functions
print(double_number(10)) # 20
print(double_number(3.14)) # 6.28
print(process_data("hello")) # HELLO
print(process_data(["a", "b"])) # a, b
5. Advanced Usage and Patterns
from typing import Union, Optional, List, Dict
# Optional is a shorthand for Union[X, None]
def greet(name: Optional[str] = None) -> str:
return f"Hello, {name or 'World'}!"
# Nested Unions
JsonValue = Union[
str,
int,
float,
bool,
None,
List['JsonValue'],
Dict[str, 'JsonValue']
]
def process_json(data: JsonValue) -> str:
return str(data)
6. Common Use Cases
- Error Handling
def divide(a: float, b: float) -> Union[float, str]:
try:
return a / b
except ZeroDivisionError:
return "Division by zero is not allowed"
- Optional Parameters
def create_user(
name: str,
age: Optional[int] = None,
email: Optional[str] = None
) -> dict:
user = {"name": name}
if age is not None:
user["age"] = age
if email is not None:
user["email"] = email
return user
7. Best Practices and Tips
- Type Narrowing
def process_value(value: Union[int, str]) -> str:
# Type narrowing using isinstance
if isinstance(value, int):
return str(value * 2) # Type checker knows value is int here
return value.upper() # Type checker knows value is str here
- Using TypeAlias for Complex Unions
from typing import TypeAlias
# Create a type alias for complex union types
NumberType: TypeAlias = Union[int, float, complex]
StringList: TypeAlias = Union[str, list[str]]
8. Common Pitfalls to Avoid
- Unnecessary Unions
# Bad: Unnecessary Union
def process_data(value: Union[str, str]) -> str:
return value.upper()
# Good: Simple type hint
def process_data(value: str) -> str:
return value.upper()
- Overuse of Union Types
# Bad: Too many types can make code hard to maintain
def complex_function(
data: Union[str, int, float, list, dict, tuple]
) -> Union[str, int, float, list, dict, tuple]:
return data
# Good: Consider creating a custom type or narrowing the scope
from typing import Any
def complex_function(data: Any) -> Any:
return data
Key Takeaways
- Union types provide flexibility in type hints while maintaining type safety
- They're particularly useful for functions that can handle multiple types
- Type narrowing is important when working with Union types
- Python 3.10+ offers a more concise syntax with the
|operator - Optional is a special case of Union with None
Callable
Python's Callable type hint is particularly useful when working with functions as arguments or return values.
Core Concept: The Callable type hint specifies that an object can be called like a function. It's part of the typing module and has two main components: the argument types and the return type.
Let's break this down with clear examples and visualizations:
1. Basic Syntax:
from typing import Callable
# General form:
# Callable[[ArgumentType1, ArgumentType2, ...], ReturnType]
Let's visualize the structure:
Callable
|
[Input Types] -> Return Type
| |
+------+------+ |
| | | |
Type1 Type2 Type3 Type
2. Common Usage Patterns:
from typing import Callable
# Function that takes no arguments and returns str
no_args: Callable[[], str]
# Function that takes int, str and returns bool
checker: Callable[[int, str], bool]
# Function that takes multiple arguments of same type
processor: Callable[[str, str, str], list[str]]
3. Practical Examples:
from typing import Callable
# Example 1: Simple callback
def execute_with_callback(callback: Callable[[int], None]) -> None:
result = 42
callback(result)
# Example 2: Function that returns a function
def create_multiplier(factor: int) -> Callable[[int], int]:
def multiplier(x: int) -> int:
return x * factor
return multiplier
# Example 3: Higher-order function
def apply_operation(numbers: list[int],
operation: Callable[[int], int]) -> list[int]:
return [operation(num) for num in numbers]
Let's create a visual representation of Example 3's flow:
numbers [1,2,3] operation(x)
| |
v v
+-------+ +---------+
| [1] |--------→ | x * 2 | ----→ [2]
| [2] |--------→ | | ----→ [4]
| [3] |--------→ | | ----→ [6]
+-------+ +---------+
| |
v v
Input List Callable Function
4. Advanced Usage:
from typing import Callable, TypeVar, ParamSpec
# Generic types for more flexible callable signatures
T = TypeVar('T')
P = ParamSpec('P')
# Generic callback type
def register_callback(callback: Callable[P, T]) -> Callable[P, T]:
return callback
# Multiple callable parameters
def compose(f: Callable[[int], str],
g: Callable[[str], bool]) -> Callable[[int], bool]:
return lambda x: g(f(x))
5. Common Patterns and Best Practices:
from typing import Callable
# ✅ Good Practices
def process_data(processor: Callable[[str], str]) -> str:
return processor("data")
# ✅ Optional parameters with default values
# str() is itself a callable (function) that can convert an integer to a string
def with_default(callback: Callable[[int], str] = str) -> str:
return callback(42)
# ❌ Avoid: Ambiguous callable types
def bad_example(func: Callable): # Too generic!
return func()
6. Key Insights:
- Type Safety: Callable helps catch type-related errors at development time.
- Documentation: It serves as self-documenting code for function signatures.
- IDE Support: Modern IDEs can provide better autocomplete and error detection.
- Flexibility: Can represent any callable object (functions, methods, lambdas).
7. Common Gotchas:
- Overloaded Functions: May need
@overloaddecorator for multiple signatures - Method Types: Instance methods need special handling due to 'self' parameter
- Variadic Arguments: Use
...for variable number of arguments
Example of handling these cases:
from typing import Callable, overload
# Overloaded function
@overload
def process(func: Callable[[int], int]) -> int: ...
@overload
def process(func: Callable[[str], str]) -> str: ...
def process(func):
return func(42)
# Variadic arguments
from typing import Any
handler: Callable[..., None] # Any number of arguments
ClassVar
1. What is ClassVar?
ClassVar is a special type annotation that explicitly marks class variables in Python. It was introduced in Python 3.5.3 (via PEP 526) to distinguish between class and instance variables during static type checking.
Let's visualize how ClassVar works in memory:
Class Definition
+------------------+
ClassVar[int] -->| counter = 0 |
|------------------|
str ------------>| name |
| age: int |
+------------------+
|
+---------------+---------------+
| |
Instance 1 Instance 2
+------------------+ +------------------+
| name = "John" | | name = "Jane" |
| age = 30 | | age = 25 |
+------------------+ +------------------+
counter (shared) -------------------------^
2. Basic Usage
from typing import ClassVar
class Student:
# Class variable
total_students: ClassVar[int] = 0
# Instance variables
name: str
age: int
def __init__(self, name: str, age: int):
self.name = name
self.age = age
Student.total_students += 1
3. Type Checking Flow
Let's create a diagram showing how type checkers handle ClassVar:
flowchart TD
A[Class Definition] --> B{Type Checker}
B --> C[Check ClassVar annotations]
C --> D{Variable Access}
D -->|Via Class| E[Allow]
D -->|Via Instance| F[Warning]
E --> G[Verify Type]
F --> H[Type Error]
4. Common Use Cases
- Counters and Statistics:
class Transaction:
total_transactions: ClassVar[int] = 0
total_amount: ClassVar[float] = 0.0
def __init__(self, amount: float):
self.amount = amount
Transaction.total_transactions += 1
Transaction.total_amount += amount
- Configuration and Constants:
class DatabaseConfig:
DEFAULT_PORT: ClassVar[int] = 5432
DEFAULT_HOST: ClassVar[str] = "localhost"
TIMEOUT: ClassVar[int] = 30
def __init__(self, host: str = None):
self.host = host or self.DEFAULT_HOST
- Cache and Shared Resources:
from typing import ClassVar, Dict, Optional
class Cache:
_cache: ClassVar[Dict[str, str]] = {}
@classmethod
def get(cls, key: str) -> Optional[str]:
return cls._cache.get(key)
@classmethod
def set(cls, key: str, value: str) -> None:
cls._cache[key] = value
5. Common Patterns and Best Practices
- Type Variations:
class Examples:
# Basic types
count: ClassVar[int] = 0
name: ClassVar[str] = "Default"
# Container types
valid_states: ClassVar[list[str]] = ["PENDING", "ACTIVE", "CLOSED"]
config: ClassVar[dict[str, str]] = {"env": "prod"}
# Optional types
cache: ClassVar[Optional[dict]] = None
- Class Methods and ClassVar:
class Registry:
_registry: ClassVar[dict] = {}
@classmethod
def register(cls, name: str, value: any) -> None:
cls._registry[name] = value
@classmethod
def get(cls, name: str) -> any:
return cls._registry.get(name)
6. What Not to Do with ClassVar
class BadExample:
# DON'T: Instance-specific data as ClassVar
user_data: ClassVar[dict] = {} # This will be shared!
# DON'T: Mutable default values as ClassVar
items: ClassVar[list] = [] # Shared list - dangerous!
# DON'T: Try to reassign through instance
def wrong_update(self):
self.user_data = {} # Type checker will warn
7. Common Mistakes and Solutions
- Mistake: Modifying ClassVar through instance
class Wrong:
counter: ClassVar[int] = 0
def increment(self):
self.counter += 1 # WRONG: Type checker will complain
class Right:
counter: ClassVar[int] = 0
def increment(self):
Right.counter += 1 # Correct: Access through class
- Mistake: Using ClassVar for instance-specific data
class Wrong:
items: ClassVar[list] = [] # WRONG: Shared between instances
class Right:
_all_items: ClassVar[list] = [] # Class-level tracking
def __init__(self):
self.items: list = [] # Instance-specific list
Key Insights:
ClassVarhelps type checkers distinguish between class and instance variables- Use it for data that should be shared across all instances
- Always access
ClassVarthrough the class, not instances - Perfect for counters, registries, and shared resources
- Be careful with mutable
ClassVarvalues
ParamSpec
ParamSpec is a powerful feature introduced in Python 3.10 for type hinting callable parameters. It's particularly useful when you need to preserve the exact parameter types of a callable.
1. Core Concept: ParamSpec captures the entire parameter specification of a callable, including:
- Positional parameters
- Keyword parameters
- Default values
- *args and **kwargs
Let's visualize how ParamSpec works:
Function Signature
↓
def func(a: int, b: str, *args, **kwargs)
└─────────┬──────────┘
│
ParamSpec 'P'
captures all of this
2. Practical Example with Detailed Breakdown:
from typing import Callable, TypeVar, ParamSpec
# Define our type variables
T = TypeVar('T') # For return type
P = ParamSpec('P') # For parameters
def create_logged_function(func: Callable[P, T]) -> Callable[P, T]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"Result: {result}")
return result
return wrapper
# Example usage
@create_logged_function
def add(x: int, y: int, *, multiply: bool = False) -> int:
return x * y if multiply else x + y
Let's create a visual representation of how ParamSpec preserves the function signature:
flowchart TD
A[Original Function] --> B[Parameter Specification]
B --> C{ParamSpec}
C --> D[Positional Args]
C --> E[Keyword Args]
C --> F[Default Values]
B --> G[Wrapper Function]
G --> H[Preserves Original Signature]
3. Why ParamSpec is Important:
Without ParamSpec:
# ❌ Less precise without ParamSpec
def bad_decorator(func: Callable[..., T]) -> Callable[..., T]:
def wrapper(*args, **kwargs) -> T: # Lost type information
return func(*args, **kwargs)
return wrapper
# ✅ Better with ParamSpec
def good_decorator(func: Callable[P, T]) -> Callable[P, T]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: # Preserves types
return func(*args, **kwargs)
return wrapper
4. Advanced Usage Examples:
from typing import Callable, TypeVar, ParamSpec
T = TypeVar('T')
P = ParamSpec('P')
# Example 1: Function validator
def validate_output(predicate: Callable[[T], bool]) -> Callable[[Callable[P, T]], Callable[P, T]]:
def decorator(func: Callable[P, T]) -> Callable[P, T]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
result = func(*args, **kwargs)
if not predicate(result):
raise ValueError(f"Invalid result: {result}")
return result
return wrapper
return decorator
# Example 2: Retry decorator
def retry(attempts: int) -> Callable[[Callable[P, T]], Callable[P, T]]:
def decorator(func: Callable[P, T]) -> Callable[P, T]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
for i in range(attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if i == attempts - 1:
raise
raise RuntimeError("Should not reach here")
return wrapper
return decorator
5. Common Use Cases:
- Function Decorators:
from typing import Callable, TypeVar, ParamSpec
T = TypeVar('T')
P = ParamSpec('P')
def timing_decorator(func: Callable[P, T]) -> Callable[P, T]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
import time
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"Function {func.__name__} took {end - start:.2f} seconds")
return result
return wrapper
- Function Factories:
def create_cached_function(func: Callable[P, T]) -> Callable[P, T]:
cache = {}
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
key = (args, frozenset(kwargs.items()))
if key not in cache:
cache[key] = func(*args, **kwargs)
return cache[key]
return wrapper
6. Key Benefits:
- Type Safety: Maintains complete type information throughout the call chain
- IDE Support: Better autocomplete and error detection
- Documentation: Self-documenting code with precise type specifications
- Flexibility: Works with any callable signature
empty tuple
Let's explore the type annotation tuple[()] in Python and how it specifically represents an empty tuple.
1. Understanding Type Annotations for Tuples
In Python's type system, tuple types are represented in two main ways:
# General tuple with specific types
tuple[int, str] # A tuple containing an int followed by a str
tuple[int, ...] # A variable-length tuple of integers
# Empty tuple
tuple[()] # A tuple with exactly zero elements
2. Breaking Down tuple[()]
Let's understand why this syntax works:
tuple[()]
|
+----------+----------+
| |
tuple (type) () (empty)
| |
Generic Type Empty Tuple Value
The reason tuple[()] works is because:
()is the literal syntax for an empty tuple- When used inside
tuple[...], it specifies that we want a tuple type with exactly zero elements
3. Practical Example
def accepts_empty_tuple(x: tuple[()]) -> None:
assert len(x) == 0, "Must be empty tuple"
# Valid usage
accepts_empty_tuple(())
# Invalid usage - will fail type checking
accepts_empty_tuple((1,)) # Wrong: Has elements
accepts_empty_tuple(tuple()) # OK: Runtime empty tuple
accepts_empty_tuple([]) # Wrong: List, not tuple
4. Alternative Representations
It's worth noting there are other ways to work with empty tuples:
# These are equivalent at runtime
empty_tuple1: tuple[()] # Most explicit
empty_tuple2: Tuple[()] # Using capital Tuple (older style)
empty_tuple3 = () # Direct assignment
empty_tuple4 = tuple() # Constructor
5. Type Hierarchy Visualization
Here's how empty tuple type fits in the type hierarchy:
Sequence
|
Tuple
/ | \
Tuple[T] | Tuple[()]
|
tuple[Any]
6. Key Insights
-
Specificity:
tuple[()]is more specific than justtuple. It tells both humans and type checkers that the tuple must be empty. -
Type Safety: This annotation helps catch errors at type-checking time rather than runtime:
def process_empty_tuple(t: tuple[()]) -> None:
pass
# Type checker will catch these errors:
process_empty_tuple((1,)) # Error: Expected empty tuple
process_empty_tuple([]) # Error: Expected tuple, got list
- Runtime Behavior: At runtime, all empty tuples are the same:
# All these are True
() == tuple()
len(()) == 0
isinstance((), tuple)
7. Common Use Cases
Empty tuples are often used in these scenarios:
- Sentinel values
- Default arguments
- Return values from functions that complete successfully but have no data to return
- Type constraints in generic programming
This type annotation is particularly useful when you want to be explicit about requiring an empty tuple, rather than just any tuple or any empty sequence.
Common Scenarios
I'll elaborate on the common scenarios where empty tuples and the tuple[()] type annotation are particularly useful.
1. Sentinel Values
Sentinel values are special values used to signify specific conditions. Empty tuples make great sentinels because they're immutable and singleton (all empty tuples are the same object in Python).
from typing import Union, Optional
class Cache:
SENTINEL = tuple() # Using empty tuple as sentinel
def __init__(self):
self._cache: dict[str, Union[str, tuple[()]]] = {}
def get(self, key: str) -> Optional[str]:
# Using sentinel to distinguish between cached None
# and missing values
value = self._cache.get(key, self.SENTINEL)
if value is self.SENTINEL:
return None
return value
2. Default Arguments
Empty tuples are excellent default arguments because they're immutable, preventing the common mutable default argument pitfall:
# BAD: Mutable default argument
def bad_append(item: int, items: list = []) -> list:
items.append(item)
return items
# GOOD: Using empty tuple as default
def good_append(item: int, items: tuple[()] | tuple[int, ...] = ()) -> tuple[int, ...]:
return tuple(list(items) + [item])
# Usage
result1 = good_append(1) # (1,)
result2 = good_append(2, (1,)) # (1, 2)
Let's visualize the difference in behavior:
sequenceDiagram
participant C1 as Call 1
participant ML as Mutable List
participant C2 as Call 2
Note over C1,C2: Bad Function (Mutable Default)
C1->>ML: append(1)
Note right of ML: [1]
C2->>ML: append(2)
Note right of ML: [1, 2]
Note over C1,C2: Good Function (Immutable Tuple)
C1->>C1: Create new tuple (1,)
C2->>C2: Create new tuple (1, 2)
3. Return Values for "No Data" Operations
Empty tuples are perfect for operations that succeed but have no meaningful data to return:
from typing import Union, Literal
from dataclasses import dataclass
@dataclass
class DatabaseConnection:
host: str
port: int
class DatabaseOperations:
def execute_query(self, query: str) -> Union[tuple[()], tuple[str, ...]]:
if query.strip().upper().startswith("SELECT"):
return ("result1", "result2") # Return data for SELECT
else:
return () # No data for INSERT/UPDATE/DELETE
def connect(self) -> tuple[()] | Literal[False]:
try:
# Attempt connection
return () # Success, no data to return
except Exception:
return False # Connection failed
4. Type Constraints in Generic Programming
Empty tuples are useful in generic programming when you need to constrain type parameters:
from typing import TypeVar, Generic
T = TypeVar('T')
class ProcessingPipeline(Generic[T]):
def __init__(self, initial_data: T):
self.data = initial_data
def process(self) -> tuple[()] | tuple[T]:
if self._should_filter_out():
return () # Nothing to return
return (self.data,) # Return processed data
# Usage with type checking
pipeline_int = ProcessingPipeline[int](42)
result_int: tuple[()] | tuple[int] = pipeline_int.process()
pipeline_str = ProcessingPipeline[str]("hello")
result_str: tuple[()] | tuple[str] = pipeline_str.process()
Let's visualize the processing pipeline:
flowchart TD
A[Input Data] --> B{Should Filter?}
B -->|Yes| C["Return tuple[()]<br/>Empty Tuple"]
B -->|No| D["Return tuple[T]<br/>Single Item Tuple"]
subgraph "Type Safety"
E["Type Checker ensures<br/>consistent types"]
end
5. State Machine Transitions
Empty tuples can represent "no-op" transitions in state machines:
from enum import Enum, auto
from typing import TypeVar, Union
class State(Enum):
IDLE = auto()
RUNNING = auto()
PAUSED = auto()
T = TypeVar('T')
class StateMachine:
def __init__(self):
self.state = State.IDLE
def transition(self,
new_state: State
) -> Union[tuple[()], tuple[str]]:
if new_state == self.state:
return () # No transition needed
if self._is_valid_transition(new_state):
old_state = self.state
self.state = new_state
return (f"Transitioned from {old_state} to {new_state}",)
return () # Invalid transition
Here's a visualization of the state machine:
stateDiagram-v2
[*] --> IDLE
IDLE --> RUNNING: tuple[str]
RUNNING --> PAUSED: tuple[str]
PAUSED --> RUNNING: tuple[str]
IDLE --> IDLE: tuple[()]
RUNNING --> RUNNING: tuple[()]
PAUSED --> PAUSED: tuple[()]
These scenarios showcase how empty tuples and tuple[()] provide type-safe, immutable, and semantically meaningful ways to handle various programming patterns. The type annotation helps catch errors at compile-time and makes the code's intentions clearer.
instance var
Let's start with an in-depth look at instance variables and their typing in Python.
1. Basic Instance Variable Type Hints
The primary way to type instance variables in Python is to declare them in the class body with annotations. Unlike ClassVar, instance variables don't need a special type - you just declare them directly:
from typing import Optional
class Person:
# Instance variables with type hints
name: str # Required string instance variable
age: int # Required integer instance variable
email: Optional[str] = None # Optional instance variable with default
2. Understanding Instance vs Class Variables
Let's visualize the difference between instance and class variables:
Person Class
+-----------------+
ClassVar[int] --> | total_count |
|-----------------|
| name: str | <-- Instance var
| age: int | <-- Instance var
| email: str | <-- Instance var
+-----------------+
▲
|
+-----------------+
Instance 1 | name: "John" |
| age: 30 |
| email: "j@..."|
+-----------------+
▲
|
+-----------------+
Instance 2 | name: "Jane" |
| age: 25 |
| email: "e@..."|
+-----------------+
3. Type Checkers and PEP 526
Let's look at how type checkers interpret instance variables. I'll create a mermaid diagram to show the flow:
flowchart TD
A[Source Code with Type Hints] --> B{Type Checker}
B --> C[Check Class Variables]
B --> D[Check Instance Variables]
D --> E[Verify __init__ assignments]
D --> F[Check method usage]
E --> G{Valid Types?}
F --> G
G -->|Yes| H[Type Check Passes]
G -->|No| I[Type Error]
4. Advanced Instance Variable Patterns
Here are some advanced patterns for typing instance variables:
from typing import TypeVar, Generic, Optional, Union
T = TypeVar('T')
class Container(Generic[T]):
# Generic instance variable
value: T
# Union type instance variable
id: Union[int, str]
# Optional with default
metadata: Optional[dict] = None
def __init__(self, value: T, id: Union[int, str]):
self.value = value
self.id = id
5. Instance Variables vs ClassVar
Here's a complete example showing both:
from typing import ClassVar, Optional
class Student:
# Class variable (shared across all instances)
total_students: ClassVar[int] = 0
# Instance variables (unique to each instance)
name: str
grade: float
student_id: int
notes: Optional[str] = None
def __init__(self, name: str, grade: float, student_id: int):
self.name = name
self.grade = grade
self.student_id = student_id
Student.total_students += 1
6. Best Practices and Common Patterns
- Default Values:
class Configuration:
# Instance variables with defaults
host: str = "localhost"
port: int = 8080
debug: bool = False
- Property Decorators:
class Circle:
radius: float
@property
def area(self) -> float:
return 3.14159 * self.radius ** 2
- Private Variables:
class Account:
_balance: float # Protected
__secret: str # Private
7. Common Gotchas and Solutions
- Initialization Order:
class Wrong:
# Type checker might complain
x: int
class Right:
x: int
def __init__(self):
self.x = 0 # Always initialize in __init__
- Mutable Defaults:
class Better:
items: list[str] = [] # WRONG: shared mutable state
def __init__(self):
self.items: list[str] = [] # RIGHT: instance-specific list
High-Level Insights:
- Unlike
ClassVar, regular instance variables don't need a special type constructor - they're the default. - Type hints for instance variables serve two purposes:
- Documentation for developers
- Static type checking
- Instance variables should generally be initialized in
__init__ - The type system is gradual - you can mix typed and untyped code
literalstring
1. LiteralString vs Literal Type
While they might sound similar, LiteralString and Literal serve different purposes:
Literal (which you're familiar with):
from typing import Literal
# Restricts values to specific literals
status: Literal["success", "error"] = "success"
LiteralString:
- Introduced in Python 3.11 (PEP 675)
- Used to indicate that a string is "literal" - meaning it's known at compile time
- Helps prevent SQL injection and similar security vulnerabilities
- Cannot be constructed from user input or dynamic strings
Example usage:
from typing import LiteralString
def unsafe_query(sql: str):
# Accepts any string, potentially unsafe
pass
def safe_query(sql: LiteralString):
# Only accepts literal strings, safer
pass
# These are valid LiteralString values:
safe_query("SELECT * FROM users")
TABLE = "users"
safe_query(f"SELECT * FROM {TABLE}") # OK if TABLE is a constant
# These are NOT valid LiteralString values:
user_input = input("Enter table: ")
safe_query(f"SELECT * FROM {user_input}") # Type error
safe_query(user_input) # Type error
High-Level Insights:
-
Security Implications:
LiteralStringis part of Python's type system security features- Helps prevent injection vulnerabilities by ensuring strings are known at compile time
- Type checkers can verify that sensitive functions only receive safe strings
-
Best Practices:
- Use
LiteralStringfor security-sensitive string parameters
- Use
Self type
Self type in Python was introduced in Python 3.11 to improve type hinting for methods that return instances of their own class.
1. The Problem Self Type Solves
Let's first understand why we need Self type with a visual representation:
Before Self Type:
Builder
+----------------+
| build() |--┐
+----------------+ |
▲ |
| |
└-----------┘
Returns "Builder" (awkward)
With Self Type:
Builder
+----------------+
| build() |--┐
+----------------+ |
▲ |
| |
└-----------┘
Returns "Self" (precise)
Old Style (before Python 3.11)
class Tree:
def get_child(self) -> 'Tree': # String literal forward reference
return Tree()
Modern Style (Python 3.11+)
from typing import Self # Available in Python 3.11+
class Tree:
def get_child(self) -> Self: # Better! More explicit and type-safe
return Tree()
Let's visualize the differences and use cases:
flowchart TD
A[Forward References] --> B{When to Use}
B -->|Pre-Python 3.11| C["String Literal 'Tree'"]
B -->|Python 3.11+| D["typing.Self"]
E[Use Cases] --> F["Method Returns<br/>Same Type"]
E --> G["Builder Pattern"]
E --> H["Fluent Interface"]
F --> I["class Tree:<br/>def clone() -> Self"]
G --> J["class Builder:<br/>def add() -> Self"]
H --> K["class Query:<br/>def where() -> Self"]
2. Basic Usage
from typing import Self
class Builder:
def __init__(self) -> None:
self.result = []
def add_item(self, item: str) -> Self: # Returns Self instead of 'Builder'
self.result.append(item)
return self # Method chaining
def build(self) -> list[str]:
return self.result
# Usage
builder = Builder().add_item("a").add_item("b").build()
3. Self Type vs Other Return Types
Let's create a diagram showing different method return types:
classDiagram
class BaseClass {
+method1() Self
+method2() BaseClass
+method3() str
}
class SubClass {
+method1() Self
+method2() BaseClass
+method3() str
}
BaseClass <|-- SubClass
note for BaseClass "Self type adapts\nto actual class"
note for SubClass "Inherits methods\nwith correct types"
4. Advanced Use Cases
- Self-Returning Methods:
from typing import Self
class TreeNode:
def __init__(self, value: int) -> None:
self.value = value
self.left: TreeNode | None = None
self.right: TreeNode | None = None
def add_left(self, value: int) -> Self:
self.left = TreeNode(value)
return self # Returns self for method chaining
def clone(self) -> Self:
new_node = TreeNode(self.value)
if self.left:
new_node.left = self.left.clone()
if self.right:
new_node.right = self.right.clone()
return new_node
- Builder Pattern:
class QueryBuilder:
def __init__(self) -> None:
self.query_parts: list[str] = []
def select(self, fields: str) -> Self:
self.query_parts.append(f"SELECT {fields}")
return self
def where(self, condition: str) -> Self:
self.query_parts.append(f"WHERE {condition}")
return self
def build(self) -> str:
return " ".join(self.query_parts)
5. Self Type with Inheritance
class Animal:
def clone(self) -> Self:
return type(self)()
class Dog(Animal):
def bark(self) -> None:
print("Woof!")
# The return type is correctly inferred
dog = Dog()
cloned_dog = dog.clone() # Type is Dog, not Animal
6. When to Use Each Approach:
# 1. Use Self when the method returns the same type
class Counter:
def __init__(self, start: int = 0) -> None:
self.count = start
def increment(self) -> Self: # ✅ Perfect use case for Self
self.count += 1
return self
# 2. Use string literal when you need to reference a type before it's defined
class CircularRef:
def get_next(self) -> "CircularRef": # Still valid when needed
return CircularRef()
# 3. Use Self for inheritance scenarios
class Animal:
def reproduce(self) -> Self: # Works correctly with inherited classes
return self.__class__()
class Dog(Animal):
pass # reproduce() will return Dog, not Animal
7. Important Considerations and Edge Cases
- Mixin Classes:
from typing import TypeVar, Self
T = TypeVar('T')
class LoggerMixin:
def with_logging(self) -> Self: # Works with any class that includes this mixin
print(f"Logging enabled for {type(self).__name__}")
return self
- Abstract Base Classes:
from abc import ABC, abstractmethod
class Clonable(ABC):
@abstractmethod
def clone(self) -> Self:
pass
8. Key Insights
Selfis more precise than using the class name as a return type annotation- It automatically adapts to inherited classes
- Perfect for builder patterns and method chaining
- Helps catch type errors in inheritance hierarchies
- Works well with alternative constructors and factory methods
Pitfalls to Avoid:
class Wrong:
@classmethod
def create(cls) -> Self: # ❌ Error! Self can't be used with @classmethod
return cls()
@staticmethod
def make() -> Self: # ❌ Error! Self can't be used with @staticmethod
return Wrong()
class Right:
@classmethod
def create(cls) -> "Right": # ✅ Use string literal here
return cls()
Which to Choose?
-
Use Self (Python 3.11+) when:
- Method returns instance of the same class
- Building fluent interfaces
- Implementing builder patterns
- Working with inheritance
-
Use string literal forward references when:
- Working with older Python versions
- Dealing with circular references
- Using @classmethod or @staticmethod
- The type isn't available at definition time
typed-dict
Python's TypedDict is a more structured way to define dictionary types with specific keys and value types.
1. Understanding TypedDict
TypedDict allows you to define a dictionary type with a fixed set of keys, where each key has a specific value type. Think of it as a "schema" for your dictionaries.
Basic Structure:
from typing import TypedDict
# Basic TypedDict definition
class UserProfile(TypedDict):
name: str
age: int
email: str
Let's visualize the structure:
UserProfile TypedDict
+-------------------+
| name: str |
| age: int |
| email: str |
+-------------------+
implements
↓
{
"name": "John",
"age": 30,
"email": "j@ex.com"
}
2. Advanced TypedDict Features
from typing import TypedDict, NotRequired
# Option 1: Using NotRequired (Python 3.11+)
class ProjectSettings(TypedDict):
name: str # Required
version: NotRequired[str] # Optional
debug: NotRequired[bool] # Optional
# Option 2: Inheritance approach (for older Python versions)
class RequiredSettings(TypedDict):
name: str # Required
class ProjectSettings(RequiredSettings, total=False):
version: str # Optional
debug: bool # Optional
Let's create a diagram to show the difference:
total=True (default) total=False
+------------------+ +------------------+
| name [req] | | name [opt] |
| version [req] | | version [opt] |
| debug [req] | | debug [opt] |
+------------------+ +------------------+
Using NotRequired
+------------------+
| name [req] |
| version [opt] |
| debug [opt] |
+------------------+
The key points:
- total=False makes ALL fields optional
- For mixed required/optional fields, use either:
- NotRequired (Python 3.11+)
- Inheritance approach (older Python versions)
3. Practical Examples
from typing import TypedDict, List, Union
# Nested TypedDict structures
class Address(TypedDict):
street: str
city: str
zip_code: str
class Company(TypedDict):
name: str
industry: str
address: Address
# Usage with lists and unions
class ProjectMember(TypedDict):
id: int
role: str
access_levels: List[str]
metadata: Union[dict, None]
# Example implementation
def create_company(data: Company) -> Company:
return {
"name": data["name"],
"industry": data["industry"],
"address": data["address"]
}
4. Type Checking Behavior
from typing import TypedDict
# Basic TypedDict definition
class UserProfile(TypedDict):
name: str
age: int
email: str
# Type checker will catch these errors:
user: UserProfile = {
"name": "John",
"age": "30", # Type error: Expected int, got str
"email": "john@example.com"
}
# Missing required fields:
incomplete: UserProfile = {
"name": "John" # Type error: Missing required fields
}
# Extra fields:
extra: UserProfile = {
"name": "John",
"age": 30,
"email": "john@example.com",
"extra": "field" # Type error: Extra field not allowed
}
5. Best Practices and Tips
- Using
totalParameter:
# All fields required (default)
class Config1(TypedDict):
debug: bool
cache: bool
# All fields optional
class Config2(TypedDict, total=False):
debug: bool
cache: bool
# Mixed required/optional (Python 3.11+)
class Config3(TypedDict):
debug: Required[bool] # Required
cache: NotRequired[bool] # Optional
- Documentation with TypedDict:
class APIResponse(TypedDict):
"""API Response structure for user data.
Fields:
status: HTTP status code
data: Response payload
message: Optional status message
"""
status: int
data: dict
message: NotRequired[str]
6. Common Pitfalls and Solutions
from typing import TypedDict, Union, Any
# ❌ Avoid using Any when possible
class BadConfig(TypedDict):
settings: Any # Too permissive
# ✅ Better approach
class GoodConfig(TypedDict):
settings: Union[str, int, bool] # Specific types
# ❌ Avoid nested plain dicts
class BadNesting(TypedDict):
data: dict # Too generic
# ✅ Better approach
class DataStructure(TypedDict):
value: str
type: str
class GoodNesting(TypedDict):
data: DataStructure # Type-safe nesting
7. Advanced Use Cases
- With Enums:
from enum import Enum
from typing import TypedDict
class UserRole(Enum):
ADMIN = "admin"
USER = "user"
class UserWithRole(TypedDict):
name: str
role: UserRole # Using enum for type-safe roles
- With Literal Types:
from typing import TypedDict, Literal
class ThemeConfig(TypedDict):
mode: Literal["light", "dark"]
accent: Literal["blue", "green", "red"]
kwargs
Two Correct Ways to Type kwargs
1. Value Type Annotation
def foo(**kwargs: int | str):
...
This is the standard way to type kwargs when you want to specify what types the values can be. The **kwargs syntax automatically handles the dictionary structure, and you only need to specify the types of values.
Example usage:
foo(a=1, b="hello") # Valid
foo(x=1.0) # Invalid - float not allowed
2. TypedDict with Unpack (Python 3.12+)
from typing import TypedDict, Unpack
class Movie(TypedDict):
name: str
year: int
def foo(**kwargs: Unpack[Movie]):
...
Use this when you need to specify exact structure of the kwargs with specific field names and their types.
Example usage:
foo(name="Life of Brian", year=1979) # Valid
foo(name="Life of Brian") # Invalid - missing year
foo(name="Life of Brian", year="1979") # Invalid - year must be int
Mark all keywords as optional with total=False
class Kw(TypedDict, total=False):
key1: int
key2: str
Mark specific keywords as optional with typing.NotRequired
class Kw(TypedDict):
key1: int
key2: NotRequired[str]
Common Mistake to Avoid
# INCORRECT:
def foo(**kwargs: dict[str, int | str]):
...
This is wrong because:
- It tries to make each value a dictionary
- The
**syntax already provides the dictionary structure - Will cause type checker errors when used
Test Results Example
def foo1(**kwargs: dict[str, int | str]): # Wrong
...
def foo2(**kwargs: int | str): # Correct
...
def foo3(**kwargs: Unpack[Movie]): # Correct
...
# Test calls
foo1(**{"name": "Life of Brian", "year": 1979}) # Error
foo2(**{"name": "Life of Brian", "year": 1979}) # OK
foo3(**{"name": "Life of Brian", "year": 1979}) # OK