Skip to main content
โšก Calmops

Python Type Hints and mypy: Static Type Checking for Better Code

Introduction

Imagine this scenario: You’re working on a Python project with a team of developers. Someone passes a list to a function that expects a dictionary. The code runs fine until it hits production, where it crashes with a cryptic error message. A user loses data. Your team spends hours debugging.

This is the classic problem with Python’s dynamic typing. While flexibility is powerful, it can lead to subtle bugs that only appear at runtime. Type hints and mypy solve this problem by bringing static type checking to Pythonโ€”catching errors before your code runs.

In this guide, we’ll explore how to use type hints to make your code safer, more maintainable, and easier to work with. You’ll learn practical techniques that you can apply to your projects immediately.


The Trade-off: Dynamic vs. Static Typing

Why Python Is Dynamically Typed

Python’s dynamic typing is one of its greatest strengths:

# Dynamic typing allows flexibility
def process_data(data):
    return data.upper()

# Works with strings
print(process_data("hello"))  # Output: HELLO

# But also works with anything that has an upper() method
class CustomString:
    def upper(self):
        return "CUSTOM"

print(process_data(CustomString()))  # Output: CUSTOM

This flexibility is great for rapid development, but it comes with a cost:

# This code runs without errors...
def calculate_total(items):
    total = 0
    for item in items:
        total += item["price"]  # Assumes item is a dict
    return total

# ...until you call it with the wrong type
calculate_total([1, 2, 3])  # TypeError: list indices must be integers or slices, not str

The Problem

Without type information, you can’t catch these errors until runtime. You also lose:

  • IDE autocomplete - Your editor doesn’t know what methods are available
  • Self-documentation - Readers must guess what types functions expect
  • Early error detection - Bugs hide until they reach production
  • Refactoring confidence - Changing code is risky without knowing types

The Solution: Type Hints

Type hints let you specify what types your code expects, enabling static type checking:

def calculate_total(items: list[dict]) -> float:
    total = 0.0
    for item in items:
        total += item["price"]
    return total

# Now mypy can catch this error before runtime
calculate_total([1, 2, 3])  # mypy error: Argument 1 to "calculate_total" has incompatible type "list[int]"; expected "list[dict]"

What Are Type Hints?

The Basics

Type hints are annotations that specify what types variables, function parameters, and return values should have. They’re optional metadata that Python ignores at runtime but tools like mypy can analyze.

# Variable type hint
name: str = "Alice"
age: int = 25
score: float = 95.5
is_active: bool = True

# Function parameter type hints
def greet(name: str) -> str:
    return f"Hello, {name}!"

# Function with multiple parameters
def add(x: int, y: int) -> int:
    return x + y

# Function with no return value
def print_message(message: str) -> None:
    print(message)

A Brief History: PEP 484

Type hints were introduced in Python 3.5 through PEP 484. They were designed to:

  • Enable static type checking without changing runtime behavior
  • Improve IDE support and autocomplete
  • Serve as documentation
  • Catch bugs early in development

Python’s approach is unique: type hints are completely optional and don’t affect how code runs. This allows gradual adoptionโ€”you can add type hints to existing code incrementally.


Basic Type Hint Syntax

Variables

# Simple types
name: str = "Alice"
age: int = 25
height: float = 5.9
is_student: bool = True

# Collections
numbers: list = [1, 2, 3]
mapping: dict = {"key": "value"}
unique: set = {1, 2, 3}
pair: tuple = (1, "two")

# More specific collection types (Python 3.9+)
numbers: list[int] = [1, 2, 3]
mapping: dict[str, int] = {"a": 1, "b": 2}
unique: set[str] = {"red", "green", "blue"}
pair: tuple[int, str] = (1, "two")

Functions

# Function with parameter and return type hints
def add(x: int, y: int) -> int:
    return x + y

# Function with multiple parameters
def create_user(name: str, age: int, email: str) -> dict:
    return {"name": name, "age": age, "email": email}

# Function with no return value
def print_info(name: str, age: int) -> None:
    print(f"{name} is {age} years old")

# Function with optional parameters
def greet(name: str, greeting: str = "Hello") -> str:
    return f"{greeting}, {name}!"

Classes

class User:
    name: str
    age: int
    email: str
    
    def __init__(self, name: str, age: int, email: str) -> None:
        self.name = name
        self.age = age
        self.email = email
    
    def get_info(self) -> str:
        return f"{self.name} ({self.age})"
    
    def update_email(self, new_email: str) -> None:
        self.email = new_email

Common Built-in Types

Basic Types

# Primitives
name: str = "Alice"
age: int = 25
height: float = 5.9
is_active: bool = True
nothing: None = None

# Collections
numbers: list[int] = [1, 2, 3]
mapping: dict[str, int] = {"a": 1, "b": 2}
unique: set[str] = {"red", "green"}
pair: tuple[int, str] = (1, "two")

Type Aliases

For complex types, create aliases for readability:

# Define a type alias
UserId = int
UserData = dict[str, str]

def get_user(user_id: UserId) -> UserData:
    return {"name": "Alice", "email": "[email protected]"}

Advanced Type Hints

Union Types

When a value can be multiple types:

from typing import Union

# Function that accepts int or str
def process_id(user_id: Union[int, str]) -> str:
    return f"User ID: {user_id}"

# Python 3.10+ syntax (preferred)
def process_id(user_id: int | str) -> str:
    return f"User ID: {user_id}"

Optional Types

When a value can be a type or None:

from typing import Optional

# Function that might return None
def find_user(user_id: int) -> Optional[dict]:
    if user_id == 1:
        return {"name": "Alice"}
    return None

# Python 3.10+ syntax (preferred)
def find_user(user_id: int) -> dict | None:
    if user_id == 1:
        return {"name": "Alice"}
    return None

Generic Types

For functions that work with any type:

from typing import TypeVar, Generic, List

T = TypeVar('T')  # Generic type variable

def get_first(items: list[T]) -> T:
    return items[0]

# Works with any type
first_int = get_first([1, 2, 3])      # Type: int
first_str = get_first(["a", "b"])     # Type: str

Callable Types

For functions as parameters:

from typing import Callable

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)  # result: 8

What Is mypy?

Introduction to mypy

mypy is a static type checker for Python. It analyzes your code without running it and reports type errors. Think of it as a spell-checker for types.

# example.py
def greet(name: str) -> str:
    return f"Hello, {name}!"

# This is fine
print(greet("Alice"))

# This will be caught by mypy
print(greet(25))  # mypy error: Argument 1 to "greet" has incompatible type "int"; expected "str"

Why Use mypy?

โœ… Catch bugs early - Before code reaches production
โœ… Better IDE support - Autocomplete and error highlighting
โœ… Self-documenting code - Types serve as documentation
โœ… Refactoring confidence - Know when changes break things
โœ… Gradual adoption - Add types incrementally


Installing and Configuring mypy

Installation

# Install mypy
pip install mypy

# Verify installation
mypy --version

Basic Usage

# Check a single file
mypy example.py

# Check a directory
mypy src/

# Check with strict mode (most strict checking)
mypy --strict example.py

Configuration File

Create a mypy.ini or pyproject.toml file:

# mypy.ini
[mypy]
python_version = 3.11
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True

Or in pyproject.toml:

[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true

Practical Example: Running mypy

Let’s create a real example and see mypy in action:

# calculator.py
def add(x: int, y: int) -> int:
    return x + y

def multiply(x: float, y: float) -> float:
    return x * y

def process_numbers(numbers: list[int]) -> int:
    total = 0
    for num in numbers:
        total += num
    return total

# Correct usage
print(add(5, 3))                    # OK
print(multiply(2.5, 4.0))          # OK
print(process_numbers([1, 2, 3]))  # OK

# Incorrect usage (mypy will catch these)
print(add("5", 3))                 # Error: Argument 1 has incompatible type "str"
print(multiply(2, 4))              # Error: Argument 1 has incompatible type "int"
print(process_numbers([1, "2", 3])) # Error: List item 1 has incompatible type "str"

Running mypy:

$ mypy calculator.py
calculator.py:16: error: Argument 1 to "add" has incompatible type "str"; expected "int"
calculator.py:17: error: Argument 1 to "multiply" has incompatible type "int"; expected "float"
calculator.py:18: error: List item 1 has incompatible type "str"; expected "int"

Common mypy Errors and Solutions

Error 1: Missing Type Hints

# Error: Function is missing a return type annotation
def calculate(x, y):
    return x + y

# Solution: Add type hints
def calculate(x: int, y: int) -> int:
    return x + y

Error 2: Type Mismatch

# Error: Argument has incompatible type
def greet(name: str) -> str:
    return f"Hello, {name}!"

greet(25)  # Error: Argument 1 has incompatible type "int"

# Solution: Pass correct type
greet("Alice")  # OK

Error 3: None Type Issues

# Error: Item is not subscriptable
def get_first(items: list[int]) -> int:
    return items[0]  # Could be None if list is empty

# Solution: Handle None case
def get_first(items: list[int]) -> int | None:
    return items[0] if items else None

Error 4: Attribute Doesn’t Exist

# Error: "int" has no attribute "upper"
def process(value: int) -> str:
    return value.upper()  # int doesn't have upper()

# Solution: Use correct type
def process(value: str) -> str:
    return value.upper()  # OK

Gradual Typing: Adopting Type Hints Incrementally

Start Small

You don’t need to type your entire codebase at once. Start with:

  1. New code - Type all new functions and classes
  2. Public APIs - Type functions that other code depends on
  3. Critical paths - Type functions that handle important data
  4. Gradually expand - Add types to more code over time

Example: Gradual Adoption

# Phase 1: No types (existing code)
def calculate_total(items):
    total = 0
    for item in items:
        total += item["price"]
    return total

# Phase 2: Add types to public API
def calculate_total(items: list[dict]) -> float:
    total = 0
    for item in items:
        total += item["price"]
    return total

# Phase 3: Add types to internal functions
def calculate_total(items: list[dict]) -> float:
    total: float = 0
    for item in items:
        total += item["price"]
    return total

Using # type: ignore

Sometimes you need to bypass mypy checks:

# Suppress mypy error for this line
result = some_untyped_function()  # type: ignore

# Suppress specific error
result = some_function(wrong_type)  # type: ignore[arg-type]

Best Practices for Type Hints

1. Be Specific

# Avoid: Too generic
def process(data: dict) -> dict:
    pass

# Better: Specific types
def process(data: dict[str, int]) -> dict[str, float]:
    pass

2. Use Type Aliases for Complex Types

# Avoid: Repeating complex types
def get_user(user_id: int) -> dict[str, str | int | bool]:
    pass

def update_user(user_id: int, data: dict[str, str | int | bool]) -> dict[str, str | int | bool]:
    pass

# Better: Use type alias
UserData = dict[str, str | int | bool]

def get_user(user_id: int) -> UserData:
    pass

def update_user(user_id: int, data: UserData) -> UserData:
    pass

3. Document Complex Types

from typing import TypedDict

class UserData(TypedDict):
    """User information."""
    name: str
    age: int
    email: str
    is_active: bool

def create_user(data: UserData) -> None:
    """Create a new user with the provided data."""
    pass

4. Use Protocols for Duck Typing

from typing import Protocol

class Drawable(Protocol):
    """Anything that can be drawn."""
    def draw(self) -> None:
        ...

def render(obj: Drawable) -> None:
    obj.draw()

5. Keep Type Hints Readable

# Avoid: Too complex on one line
def process(data: dict[str, list[tuple[int, str]]] | None) -> dict[str, list[tuple[int, str]]] | None:
    pass

# Better: Break into multiple lines or use aliases
DataType = dict[str, list[tuple[int, str]]]

def process(data: DataType | None) -> DataType | None:
    pass

Integrating mypy Into Your Workflow

Pre-commit Hook

Automatically run mypy before commits:

# Install pre-commit
pip install pre-commit

# Create .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.7.0
    hooks:
      - id: mypy
        additional_dependencies: [types-all]

CI/CD Pipeline

Add mypy to your GitHub Actions workflow:

name: Type Check

on: [push, pull_request]

jobs:
  mypy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-python@v4
        with:
          python-version: "3.11"
      - run: pip install mypy
      - run: mypy src/

IDE Integration

Most IDEs support mypy:

VS Code:

  1. Install the Python extension
  2. Set python.linting.mypyEnabled to true in settings

PyCharm:

  1. Go to Settings โ†’ Tools โ†’ Python Integrated Tools
  2. Set Default Test Runner to mypy

Real-World Example: User Management System

Let’s see type hints and mypy in action with a realistic example:

from typing import Optional
from datetime import datetime

class User:
    """Represents a user in the system."""
    
    def __init__(self, user_id: int, name: str, email: str) -> None:
        self.user_id: int = user_id
        self.name: str = name
        self.email: str = email
        self.created_at: datetime = datetime.now()
        self.is_active: bool = True
    
    def deactivate(self) -> None:
        """Deactivate the user account."""
        self.is_active = False
    
    def get_info(self) -> dict[str, str | int | bool]:
        """Get user information."""
        return {
            "id": self.user_id,
            "name": self.name,
            "email": self.email,
            "active": self.is_active
        }

class UserManager:
    """Manages user operations."""
    
    def __init__(self) -> None:
        self.users: dict[int, User] = {}
        self.next_id: int = 1
    
    def create_user(self, name: str, email: str) -> User:
        """Create a new user."""
        user = User(self.next_id, name, email)
        self.users[self.next_id] = user
        self.next_id += 1
        return user
    
    def get_user(self, user_id: int) -> Optional[User]:
        """Get a user by ID."""
        return self.users.get(user_id)
    
    def delete_user(self, user_id: int) -> bool:
        """Delete a user."""
        if user_id in self.users:
            del self.users[user_id]
            return True
        return False
    
    def list_active_users(self) -> list[User]:
        """Get all active users."""
        return [user for user in self.users.values() if user.is_active]

# Usage
manager = UserManager()
user1 = manager.create_user("Alice", "[email protected]")
user2 = manager.create_user("Bob", "[email protected]")

print(user1.get_info())
print(f"Active users: {len(manager.list_active_users())}")

# mypy will catch this error:
# manager.create_user(123, "[email protected]")  # Error: Argument 1 has incompatible type "int"

Conclusion

Type hints and mypy represent a significant step forward in Python development. By adding type information to your code, you gain:

Key takeaways:

  1. Type hints are optional - Add them gradually to existing code
  2. mypy catches errors early - Before code reaches production
  3. Better IDE support - Autocomplete and error highlighting
  4. Self-documenting code - Types serve as inline documentation
  5. Gradual adoption - Start with new code and critical paths
  6. Improved confidence - Refactor with less fear of breaking things
  7. Industry standard - Most modern Python projects use type hints

Start by adding type hints to new functions and public APIs. Run mypy regularly to catch errors. Over time, you’ll build a codebase that’s safer, more maintainable, and easier to work with.

The investment in learning type hints pays dividends in code quality and developer experience. Your future self (and your teammates) will thank you.

Happy typing! ๐Ÿโœจ


Resources

Comments