Type Hints & Linters
Catch bugs early and write self-documenting code with modern Python tooling
Why Type Hints & Linting Matter
Python's dynamic nature is powerful but can lead to runtime errors that are difficult to track down. Type hints add clarity, enable powerful IDE features, and help catch bugs before code runs. Combined with linters that enforce style and best practices, these tools transform Python into a language that scales gracefully for large teams and long-lived projects.
Benefits of Type Hints & Linting:
- Catch bugs early: Find type errors before runtime
- Better IDE support: Autocomplete, refactoring, navigation
- Self-documenting code: Types explain intent clearly
- Easier refactoring: Tools track changes across codebase
- Team consistency: Automated style enforcement
- Reduced debugging time: Issues caught in seconds, not hours
Python Typing Evolution
Python 3.5
typing module introducedPython 3.9
Built-in generics: list[int]Python 3.10
Union types: int | strPython 3.12+
Type parameter syntaxType Hints Fundamentals
Type hints describe the expected types of variables, function arguments, and return values. They're optional and don't affect runtime behavior.
Basic Type Annotations
# Variables
name: str = "Alice"
age: int = 30
price: float = 19.99
is_active: bool = True
# Functions
def greet(name: str) -> str:
return f"Hello, {name}!"
def add(a: int, b: int) -> int:
return a + b
def log_message(msg: str) -> None:
"""Functions that don't return anything use None"""
print(msg)
# Multiple arguments
def create_user(name: str, age: int, active: bool = True) -> dict:
return {"name": name, "age": age, "active": active}
# Using the annotations
result: str = greet("Bob") # Type checker verifies this is correct
total: int = add(5, 10) # total is guaranteed to be intCollection Types
Python 3.9+ allows using built-in types directly for collections. For older versions, import from typing.
# Modern Python 3.9+ (preferred)
scores: list[int] = [90, 85, 88, 92]
names: set[str] = {"Alice", "Bob", "Charlie"}
user_ids: dict[str, int] = {"alice": 1, "bob": 2}
coordinates: tuple[float, float] = (10.5, 20.3)
# Nested collections
matrix: list[list[int]] = [[1, 2], [3, 4]]
users_by_role: dict[str, list[str]] = {
"admin": ["alice", "bob"],
"user": ["charlie", "diana"]
}
# Functions with collections
def calculate_average(numbers: list[float]) -> float:
return sum(numbers) / len(numbers)
def get_user_names(users: dict[int, str]) -> list[str]:
return list(users.values())
# Python 3.8 and earlier (legacy)
from typing import List, Dict, Set, Tuple
scores: List[int] = [90, 85, 88]
users: Dict[str, int] = {"alice": 1}
tags: Set[str] = {"python", "typing"}Optional and Union Types
Optional Types (value or None)
# Modern Python 3.10+ (preferred)
def find_user(user_id: int) -> str | None:
"""Returns username or None if not found"""
if user_id == 1:
return "Alice"
return None
# Equivalent older syntax
from typing import Optional
def find_user(user_id: int) -> Optional[str]:
"""Returns username or None if not found"""
if user_id == 1:
return "Alice"
return None
# Using optional in variables
middle_name: str | None = None
config: dict[str, str] | None = load_config()
# Checking for None
result = find_user(5)
if result is not None:
print(f"Found: {result}")
else:
print("User not found")Union Types (multiple possible types)
# Modern Python 3.10+ (preferred)
def process_input(value: int | str | float) -> str:
"""Accepts int, str, or float"""
return str(value)
# Legacy syntax
from typing import Union
def process_input(value: Union[int, str, float]) -> str:
return str(value)
# Real-world example: flexible ID types
UserId = int | str # Type alias
def get_user(user_id: UserId) -> dict:
"""Accept either numeric or string IDs"""
return {"id": user_id, "name": "User"}
# Common pattern: accept multiple types, return specific type
def parse_number(value: int | str) -> int:
"""Always returns int, regardless of input type"""
if isinstance(value, str):
return int(value)
return value
result1 = parse_number(42) # int -> int
result2 = parse_number("42") # str -> intAdvanced Typing Patterns
TypedDict for Structured Dictionaries
from typing import TypedDict
# Define structure for dictionary data
class User(TypedDict):
id: int
name: str
email: str
active: bool
# Type checker verifies structure
def create_user(name: str, email: str) -> User:
return {
"id": 1,
"name": name,
"email": email,
"active": True
}
def get_user_display(user: User) -> str:
# Type checker knows these keys exist
return f"{user['name']} ({user['email']})"
# Optional keys
class UserProfile(TypedDict, total=False):
bio: str # Optional
avatar_url: str # Optional
# Usage
user: User = create_user("Alice", "alice@example.com")
print(get_user_display(user))Protocol for Structural Subtyping
from typing import Protocol
# Define interface without inheritance
class Drawable(Protocol):
def draw(self) -> None: ...
class Circle:
def draw(self) -> None:
print("Drawing circle")
class Square:
def draw(self) -> None:
print("Drawing square")
# Works with any class that has draw()
def render(shape: Drawable) -> None:
shape.draw()
# Both work - no inheritance needed!
render(Circle())
render(Square())
# Real-world example: Repository pattern
class Repository(Protocol):
def get(self, id: int) -> dict: ...
def save(self, data: dict) -> None: ...
def process_data(repo: Repository) -> None:
"""Works with any class implementing Repository protocol"""
data = repo.get(1)
data["processed"] = True
repo.save(data)Generic Types
from typing import TypeVar, Generic
# Define a type variable
T = TypeVar('T')
class Box(Generic[T]):
def __init__(self, content: T):
self.content = content
def get(self) -> T:
return self.content
# Type-specific boxes
int_box: Box[int] = Box(42)
str_box: Box[str] = Box("hello")
num: int = int_box.get() # Type checker knows this is int
text: str = str_box.get() # Type checker knows this is str
# Generic function
def first_element(items: list[T]) -> T | None:
"""Return first element or None"""
return items[0] if items else None
# Type checker infers return type
num = first_element([1, 2, 3]) # Returns int | None
name = first_element(["a", "b"]) # Returns str | NoneType Checkers: Finding Bugs Before Runtime
Type checkers analyze your code statically and report type errors without running it. They're essential for catching bugs early in development.
mypy is the reference implementation and most mature type checker. Strict, configurable, and widely adopted.
# Install pip install mypy # Basic usage mypy mypackage/ mypy --strict mypackage/ # Strictest checking # Configuration in pyproject.toml [tool.mypy] python_version = "3.11" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true disallow_any_generics = true no_implicit_optional = true # Per-module config [[tool.mypy.overrides]] module = "tests.*" disallow_untyped_defs = false # Ignore specific errors # type: ignore result = dangerous_function() # type: ignore[arg-type]
Linters & Code Formatters
Linters enforce coding standards and catch potential bugs. Formatters automatically fix style issues, eliminating debates about code formatting.
Ruff (Recommended)
Extremely fast linter written in Rust. Replaces Flake8, isort, and more.
# Install pip install ruff # Run ruff check src/ ruff check --fix src/ # Auto-fix # pyproject.toml [tool.ruff] line-length = 88 select = ["E", "F", "I", "N"] ignore = ["E501"]
Black (Formatter)
Opinionated code formatter. "Any color you want, as long as it's black."
# Install pip install black # Run black src/ black --check src/ # Check only # pyproject.toml [tool.black] line-length = 88 target-version = ["py311"]
isort (Import Sorter)
Automatically sorts and organizes imports. Often replaced by ruff.
# Install pip install isort # Run isort src/ # pyproject.toml [tool.isort] profile = "black" line_length = 88
Pylint (Deep Analysis)
Comprehensive static analyzer. Catches more issues but slower.
# Install pip install pylint # Run pylint src/ # .pylintrc or pyproject.toml [tool.pylint.messages_control] disable = ["C0111", "R0903"]
- Ruff: Fast linting and import sorting
- Black: Code formatting
- mypy or pyright: Type checking
Pre-commit Hooks: Enforce Quality Automatically
Pre-commit hooks run checks automatically before each commit, catching issues before they enter the codebase.
# Install pre-commit
pip install pre-commit
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.9
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
additional_dependencies: [types-requests]
- repo: https://github.com/psf/black
rev: 23.12.1
hooks:
- id: black
# Install hooks
pre-commit install
# Run manually on all files
pre-commit run --all-files
# Now hooks run automatically on git commit!CI/CD Integration
Run type checking and linting in continuous integration to enforce quality across the entire team.
# .github/workflows/quality.yml
name: Code Quality
on: [push, pull_request]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install ruff black mypy
pip install -e .
- name: Run Ruff
run: ruff check src/
- name: Check Black formatting
run: black --check src/
- name: Run mypy
run: mypy src/
- name: Run tests
run: pytest tests/Gradual Typing Strategy
Don't try to type everything at once. Add types gradually where they provide the most value.
Public APIs
Functions others call
Data Structures
TypedDict, dataclasses
Complex Logic
Bug-prone areas
Everything Else
Optional, as time allows
# Start with public APIs
def create_user(name: str, email: str) -> dict: # Public, type it
return _process_user(name, email) # Private, type later
# Add types to data structures
class User(TypedDict):
id: int
name: str
email: str
# Enable stricter checking gradually
# mypy.ini or pyproject.toml
[tool.mypy]
# Start lenient
check_untyped_defs = false
# Then enable module by module
[[tool.mypy.overrides]]
module = "myapp.api"
disallow_untyped_defs = true
[[tool.mypy.overrides]]
module = "myapp.models"
disallow_untyped_defs = trueBest Practices & Common Pitfalls
✓ Do This
- Start with public APIs and interfaces
- Use modern syntax (3.10+):
int | None - Enable strict mode gradually
- Run type checkers in CI/CD
- Use TypedDict for structured dicts
- Let formatters handle style
- Combine multiple tools (ruff + mypy + black)
- Document complex types
✗ Avoid This
- Using
Anyeverywhere (defeats purpose) - Over-typing internal implementation
- Ignoring type errors without understanding
- Inconsistent type checking across modules
- Not updating types when code changes
- Manual code formatting (use Black/Ruff)
- Blocking progress to achieve 100% types
- Complex generics where simple types work
Key Takeaways
- Type hints catch bugs early - find issues before runtime
- Start gradually - type public APIs first, expand over time
- Use modern syntax - Python 3.10+ has cleaner type annotations
- Combine tools - ruff + black + mypy covers all bases
- Automate everything - pre-commit hooks and CI/CD enforcement
- Let formatters handle style - no more formatting debates
- Types are documentation - they explain intent clearly
- Balance is key - don't over-type, focus on value
Practice Exercises
Exercise 1: Add Type Hints
Take an existing untyped module (20-50 lines) and add comprehensive type hints. Run mypy in strict mode and fix all reported issues. Compare IDE autocomplete before and after adding types.
Exercise 2: Set Up Tooling
Configure ruff, black, and mypy for a project using pyproject.toml. Set up pre-commit hooks to run all checks automatically. Create a GitHub Actions workflow that enforces these checks in CI.
Exercise 3: Refactor with Types
Create a simple data processing pipeline with at least 3 functions. Use TypedDict for data structures, add complete type hints, and use mypy to catch a deliberately introduced type error (pass string where int expected).
Additional Resources
- Official Docs: docs.python.org/3/library/typing.html
- mypy: mypy-lang.org - comprehensive type checking guide
- pyright: github.com/microsoft/pyright
- Ruff: docs.astral.sh/ruff - fastest linter
- PEP 484: Type Hints specification
- Real Python: Python Type Checking guide
- Book: "Robust Python" by Patrick Viafore
What's Next?
With type safety in place, let's explore Python's advanced built-in data structures for efficient programming.
- Collections Module - Master deque, Counter, defaultdict, and more
- Named Tuples - Create lightweight, immutable data containers
- Data Classes - Use modern Python features for clean data structures