Python Type System
Master Python's type system to write type-safe, maintainable code. This skill covers type hints, static type checking with mypy, and advanced typing features.
Type Checking Tools
Install mypy for static type checking
pip install mypy
Run mypy on a file or directory
mypy my_module.py mypy src/
Run with specific configuration
mypy --config-file mypy.ini src/
Run with strict mode
mypy --strict src/
Show type coverage report
mypy --html-report mypy-report src/
mypy Configuration
mypy.ini configuration file:
[mypy]
Global options
python_version = 3.11 warn_return_any = True warn_unused_configs = True disallow_untyped_defs = True disallow_any_unimported = True no_implicit_optional = True warn_redundant_casts = True warn_unused_ignores = True warn_no_return = True check_untyped_defs = True strict_equality = True
Per-module options
[mypy-tests.*] disallow_untyped_defs = False
[mypy-third_party.*] ignore_missing_imports = True
pyproject.toml configuration:
[tool.mypy] python_version = "3.11" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true disallow_any_unimported = true no_implicit_optional = true warn_redundant_casts = true warn_unused_ignores = true warn_no_return = true check_untyped_defs = true strict_equality = true
[[tool.mypy.overrides]] module = "tests.*" disallow_untyped_defs = false
Basic Type Hints
Primitive types and collections:
from typing import List, Dict, Set, Tuple, Optional, Union, Any
Basic types
def greet(name: str) -> str: return f"Hello, {name}"
Collections
def process_items(items: List[str]) -> Dict[str, int]: return {item: len(item) for item in items}
Optional (can be None)
def find_user(user_id: int) -> Optional[str]: users = {1: "Alice", 2: "Bob"} return users.get(user_id)
Union types (multiple possible types)
def process_value(value: Union[int, str]) -> str: return str(value)
Tuple with fixed types
def get_coordinates() -> Tuple[float, float]: return (37.7749, -122.4194)
Any type (avoid when possible)
def process_data(data: Any) -> None: print(data)
Modern Type Syntax (Python 3.10+)
Using PEP 604 union syntax:
Python 3.10+ union syntax with |
def process_value(value: int | str) -> str: return str(value)
Optional with | None
def find_user(user_id: int) -> str | None: users = {1: "Alice", 2: "Bob"} return users.get(user_id)
Multiple unions
def handle_response( response: dict | list | str | None ) -> str: if response is None: return "No response" return str(response)
Built-in generic types (Python 3.9+):
Use built-in types instead of typing module
def process_items(items: list[str]) -> dict[str, int]: return {item: len(item) for item in items}
def get_mapping() -> dict[str, list[int]]: return {"numbers": [1, 2, 3]}
def get_unique(items: list[str]) -> set[str]: return set(items)
Nested generics
def group_items( items: list[tuple[str, int]] ) -> dict[str, list[int]]: result: dict[str, list[int]] = {} for key, value in items: result.setdefault(key, []).append(value) return result
Generic Types
Creating generic functions and classes:
from typing import TypeVar, Generic, Sequence
Type variable
T = TypeVar("T")
def first(items: Sequence[T]) -> T | None: return items[0] if items else None
def last(items: list[T]) -> T | None: return items[-1] if items else None
Constrained type variable
Number = TypeVar("Number", int, float)
def add(a: Number, b: Number) -> Number: return a + b # type: ignore
Generic class
class Stack(Generic[T]): def init(self) -> None: self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
def peek(self) -> T | None:
return self._items[-1] if self._items else None
Usage
stack: Stack[int] = Stack() stack.push(1) stack.push(2) value: int = stack.pop()
Bound type variables:
from typing import TypeVar from collections.abc import Sized
Type variable with upper bound
TSized = TypeVar("TSized", bound=Sized)
def get_length(obj: TSized) -> int: return len(obj)
Works with any Sized type
get_length("hello") get_length([1, 2, 3]) get_length({"a": 1})
Protocol (Structural Subtyping)
Define interfaces using Protocol:
from typing import Protocol
Define a protocol
class Drawable(Protocol): def draw(self) -> str: ...
Classes that match the protocol don't need inheritance
class Circle: def draw(self) -> str: return "Drawing circle"
class Square: def draw(self) -> str: return "Drawing square"
Function accepts any type matching the protocol
def render(shape: Drawable) -> None: print(shape.draw())
Works with any matching class
render(Circle()) render(Square())
Protocol with properties and methods:
from typing import Protocol
class Comparable(Protocol): def lt(self, other: "Comparable") -> bool: ...
def __gt__(self, other: "Comparable") -> bool:
...
def find_max(items: list[Comparable]) -> Comparable: return max(items)
class Person: def init(self, name: str, age: int) -> None: self.name = name self.age = age
def __lt__(self, other: "Person") -> bool:
return self.age < other.age
def __gt__(self, other: "Person") -> bool:
return self.age > other.age
Works because Person implements Comparable protocol
people = [Person("Alice", 30), Person("Bob", 25)] oldest = find_max(people)
Runtime checkable protocols:
from typing import Protocol, runtime_checkable
@runtime_checkable class Serializable(Protocol): def to_dict(self) -> dict[str, Any]: ...
class User: def init(self, name: str) -> None: self.name = name
def to_dict(self) -> dict[str, Any]:
return {"name": self.name}
user = User("Alice") assert isinstance(user, Serializable)
TypedDict
Define dictionary shapes with TypedDict:
from typing import TypedDict, NotRequired
Basic TypedDict
class UserDict(TypedDict): id: int name: str email: str
def create_user(user: UserDict) -> UserDict: return user
user: UserDict = { "id": 1, "name": "Alice", "email": "alice@example.com" }
Optional fields (Python 3.11+)
class PersonDict(TypedDict): name: str age: int email: NotRequired[str] # Optional field
person: PersonDict = {"name": "Bob", "age": 30}
Total=False makes all fields optional
class ConfigDict(TypedDict, total=False): host: str port: int debug: bool
config: ConfigDict = {"host": "localhost"}
Inheritance with TypedDict:
from typing import TypedDict
class BaseUserDict(TypedDict): id: int name: str
class ExtendedUserDict(BaseUserDict): email: str is_active: bool
user: ExtendedUserDict = { "id": 1, "name": "Alice", "email": "alice@example.com", "is_active": True }
Literal Types
Restrict values to specific literals:
from typing import Literal
def set_mode(mode: Literal["read", "write", "append"]) -> None: print(f"Mode set to {mode}")
Valid
set_mode("read")
Type error: not a valid literal
set_mode("invalid")
Literal unions
Status = Literal["pending", "active", "completed"]
def update_status(status: Status) -> None: print(f"Status: {status}")
Literal with multiple types
MixedLiteral = Literal[True, 1, "one"]
Type Aliases
Create type aliases for complex types:
from typing import TypeAlias
Type alias
UserId: TypeAlias = int UserName: TypeAlias = str
def get_user(user_id: UserId) -> UserName: return f"User {user_id}"
Complex type alias
JsonValue: TypeAlias = ( dict[str, "JsonValue"] | list["JsonValue"] | str | int | float | bool | None )
def process_json(data: JsonValue) -> None: print(data)
Generic type alias
Vector: TypeAlias = list[float] Matrix: TypeAlias = list[Vector]
def multiply_matrix(a: Matrix, b: Matrix) -> Matrix: # Implementation return [[0.0]]
Callable Types
Type hints for functions and callables:
from typing import Callable
Function that takes a callback
def apply_operation( x: int, y: int, operation: Callable[[int, int], int] ) -> int: return operation(x, y)
def add(a: int, b: int) -> int: return a + b
result = apply_operation(5, 3, add)
Callable with no arguments
def execute(task: Callable[[], None]) -> None: task()
Callback with multiple argument types
Callback: TypeAlias = Callable[[str, int], bool]
def register_handler(callback: Callback) -> None: callback("test", 42)
ParamSpec and Concatenate
Advanced callable typing:
from typing import Callable, ParamSpec, TypeVar, Concatenate from functools import wraps
P = ParamSpec("P") R = TypeVar("R")
Decorator that preserves function signature
def log_calls( func: Callable[P, R] ) -> Callable[P, R]: @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: print(f"Calling {func.name}") return func(*args, **kwargs) return wrapper
@log_calls def add(a: int, b: int) -> int: return a + b
Concatenate adds parameters
def with_context( func: Callable[Concatenate[str, P], R] ) -> Callable[P, R]: def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return func("context", *args, **kwargs) return wrapper
Type Guards
Create type guards for runtime type narrowing:
from typing import TypeGuard
def is_str_list(val: list[object]) -> TypeGuard[list[str]]: return all(isinstance(x, str) for x in val)
def process_strings(values: list[object]) -> None: if is_str_list(values): # Type narrowed to list[str] for value in values: print(value.upper())
More complex type guard
def is_non_empty_str(val: str | None) -> TypeGuard[str]: return val is not None and len(val) > 0
def process_name(name: str | None) -> None: if is_non_empty_str(name): # Type narrowed to str (non-None) print(name.upper())
Overload
Multiple function signatures with overload:
from typing import overload, Literal
@overload def get_value(key: str, as_int: Literal[True]) -> int: ...
@overload def get_value(key: str, as_int: Literal[False]) -> str: ...
def get_value(key: str, as_int: bool) -> int | str: value = "42" return int(value) if as_int else value
Type checker knows return type based on literal
int_value: int = get_value("key", True) str_value: str = get_value("key", False)
Common Patterns
Avoiding common type checking issues:
from typing import TYPE_CHECKING, cast
Avoid circular imports
if TYPE_CHECKING: from my_module import MyClass
def process(obj: "MyClass") -> None: pass
Type casting when you know better than the type checker
def get_data() -> object: return {"key": "value"}
data = cast(dict[str, str], get_data())
Assert type with reveal_type (mypy only)
x = [1, 2, 3]
reveal_type(x) # Reveals: list[int]
Ignore type checking for specific line
result = some_untyped_function() # type: ignore[no-untyped-call]
Ignore specific error code
value: Any = get_dynamic_value() processed = process_value(value) # type: ignore[arg-type]
When to Use This Skill
Use python-type-system when you need to:
-
Add type hints to Python code for better IDE support and documentation
-
Configure mypy for static type checking in your project
-
Create reusable generic functions and classes
-
Define structural interfaces using Protocol
-
Specify exact dictionary shapes with TypedDict
-
Create type-safe decorators with ParamSpec
-
Implement runtime type narrowing with TypeGuard
-
Handle complex union types and literal types
-
Build type-safe APIs and library interfaces
Best Practices
-
Enable strict mode in mypy for maximum type safety
-
Use Protocol for structural typing instead of ABC when possible
-
Prefer built-in generic types (list, dict) over typing module (3.9+)
-
Use TypedDict for dictionary shapes instead of Dict[str, Any]
-
Create type aliases for complex types to improve readability
-
Use TYPE_CHECKING to avoid circular import issues
-
Add type hints incrementally, starting with public APIs
-
Run mypy in CI/CD to catch type errors early
-
Use reveal_type during development to debug type inference
-
Avoid Any type except when interfacing with untyped code
Common Pitfalls
-
Forgetting to handle None in Optional types
-
Using mutable default arguments (use None and create in function)
-
Not using Protocol for duck-typed interfaces
-
Overusing Any type, reducing type safety benefits
-
Not enabling strict mode in mypy configuration
-
Ignoring type errors instead of fixing them properly
-
Using old typing syntax (List, Dict) in Python 3.9+
-
Circular import issues with forward references
-
Not understanding variance in generic types
-
Mixing runtime behavior with type hints (use TypeGuard)
Resources
-
Python Type Hints Documentation
-
mypy Documentation
-
PEP 484 - Type Hints
-
PEP 544 - Protocols
-
PEP 589 - TypedDict
-
PEP 604 - Union Syntax