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 introduced
Python 3.9
Built-in generics: list[int]
Python 3.10
Union types: int | str
Python 3.12+
Type parameter syntax
Good news: Type hints have become simpler and more intuitive with each Python version. Modern Python (3.10+) has much cleaner syntax than the original typing module.

Type 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 int
Important: Type hints are hints, not enforcement. Python won't stop you from passing the wrong type at runtime. Use type checkers (mypy, pyright) to catch violations during development.

Collection 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 -> int

Advanced 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 | None

Type 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"]
Modern toolchain recommendation:
  • Ruff: Fast linting and import sorting
  • Black: Code formatting
  • mypy or pyright: Type checking
This combination provides comprehensive coverage with minimal configuration.

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!
Benefits: Consistent code quality, catches issues immediately, no manual checking needed, works for entire team.

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/
Pro tip: Fail the CI build on quality issues. This ensures all code meets standards before merging.

Gradual Typing Strategy

Don't try to type everything at once. Add types gradually where they provide the most value.

1️⃣
Public APIs

Functions others call

2️⃣
Data Structures

TypedDict, dataclasses

3️⃣
Complex Logic

Bug-prone areas

4️⃣
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 = true

Best 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 Any everywhere (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