Python Type Hints and Static Analysis with mypy: A Complete Guide

Learn how to use Python type hints and mypy to catch bugs early, improve code quality, and build maintainable Python applications with static type checking.

Python’s dynamic typing offers flexibility, but it can lead to runtime errors that could have been caught earlier. Type hints, introduced in Python 3.5, allow you to annotate your code with type information. Combined with static analysis tools like mypy, you can catch type-related bugs before your code runs.

This tutorial walks you through adding type hints to your Python projects and using mypy to verify type correctness. Whether you’re working on a small script or a large codebase, these techniques will help you write more reliable code.

Prerequisites

Before starting, you need Python 3.8 or later installed. Install mypy using pip:

pip install mypy

To verify the installation:

mypy --version

You should see output like mypy 1.8.0 or similar. Now you’re ready to add type hints to your code.

Step 1: Basic Type Annotations

Type hints use a colon after parameter names and -> before the return type. Here’s a simple example:

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

result = greet("Alice")
print(result)

This function accepts a string parameter and returns a string. Run mypy on this file:

mypy example.py

If there are no type errors, mypy reports success. Now try calling the function with the wrong type:

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

result = greet(42)  # Wrong type

Running mypy now shows an error:

example.py:4: error: Argument 1 to "greet" has incompatible type "int"; expected "str"

Type hints help you catch these mistakes during development instead of at runtime.

For basic types, use these annotations:

age: int = 25
price: float = 19.99
is_active: bool = True
message: str = "Hello"
data: bytes = b"binary"
nothing: None = None

You can also annotate function parameters without default values:

def calculate_total(price: float, quantity: int) -> float:
    return price * quantity

total = calculate_total(9.99, 3)

Step 2: Complex Types and Generics

For collections like lists and dictionaries, use types from the typing module (or built-in generics in Python 3.9+):

from typing import List, Dict, Tuple, Set, Optional

# Python 3.9+ can use lowercase
def process_numbers(numbers: list[int]) -> int:
    return sum(numbers)

def get_user_data() -> dict[str, str]:
    return {"name": "Alice", "email": "alice@example.com"}

def parse_coordinates(data: str) -> tuple[float, float]:
    x, y = data.split(",")
    return (float(x), float(y))

def unique_tags(tags: list[str]) -> set[str]:
    return set(tags)

The Optional type indicates a value can be None:

def find_user(user_id: int) -> Optional[str]:
    users = {1: "Alice", 2: "Bob"}
    return users.get(user_id)

result = find_user(3)  # Returns None
if result is not None:
    print(f"Found user: {result}")

For functions that accept multiple types, use Union:

from typing import Union

def format_id(id_value: Union[int, str]) -> str:
    return str(id_value).zfill(6)

print(format_id(42))      # "000042"
print(format_id("100"))   # "000100"

Python 3.10+ supports the | operator as a cleaner alternative:

def format_id(id_value: int | str) -> str:
    return str(id_value).zfill(6)

For callable objects (functions), use the Callable type:

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)  # Returns 8

Step 3: Type Hints for Classes and Protocols

Classes can have type-annotated attributes and methods:

class User:
    def __init__(self, name: str, age: int) -> None:
        self.name: str = name
        self.age: int = age
        self.email: Optional[str] = None

    def set_email(self, email: str) -> None:
        self.email = email

    def get_display_name(self) -> str:
        return f"{self.name} ({self.age})"

user = User("Alice", 30)
user.set_email("alice@example.com")

For generic classes, use TypeVar:

from typing import TypeVar, Generic

T = TypeVar('T')

class Box(Generic[T]):
    def __init__(self, content: T) -> None:
        self.content = content

    def get_content(self) -> T:
        return self.content

int_box = Box[int](42)
str_box = Box[str]("hello")

number = int_box.get_content()  # mypy knows this is int
text = str_box.get_content()    # mypy knows this is str

Protocols define structural typing (similar to interfaces):

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> str:
        ...

class Circle:
    def draw(self) -> str:
        return "Drawing circle"

class Square:
    def draw(self) -> str:
        return "Drawing square"

def render_shape(shape: Drawable) -> None:
    print(shape.draw())

render_shape(Circle())
render_shape(Square())

Any class with a draw method that returns a string satisfies the Drawable protocol. You don’t need explicit inheritance.

For class methods and static methods:

class MathUtils:
    @staticmethod
    def add(x: int, y: int) -> int:
        return x + y

    @classmethod
    def from_string(cls, data: str) -> "MathUtils":
        return cls()

Step 4: Configuring mypy for Your Project

Create a mypy.ini file in your project root to customize mypy’s behavior:

[mypy]
python_version = 3.10
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True

Key configuration options:

  • python_version: Target Python version
  • warn_return_any: Warn when returning Any type
  • disallow_untyped_defs: Require type annotations on all functions
  • disallow_any_unimported: Prevent Any from untyped imports
  • no_implicit_optional: Disallow implicit Optional types
  • strict_equality: Warn on comparing incompatible types

For strict checking, enable strict mode:

[mypy]
strict = True

This enables multiple strict options at once. You can exclude specific directories:

[mypy]
strict = True

[mypy-tests.*]
disallow_untyped_defs = False

For third-party packages without type stubs, you can suppress errors:

[mypy-some_package.*]
ignore_missing_imports = True

Run mypy on your entire project:

mypy src/

Or on specific files:

mypy src/main.py src/utils.py

You can also configure mypy in pyproject.toml:

[tool.mypy]
python_version = "3.10"
strict = true
warn_return_any = true
disallow_untyped_defs = true

[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false

Step 5: Gradual Typing Strategy

You don’t need to add type hints to your entire codebase at once. Start with new code and high-risk areas:

  1. New code: Add type hints as you write new functions and classes
  2. Public APIs: Type your public interfaces first
  3. Bug-prone areas: Add types where you’ve had runtime errors
  4. Core logic: Focus on business-critical code

Use # type: ignore comments to suppress specific errors temporarily:

result = some_untyped_function()  # type: ignore

Add a reason to make it clear:

result = legacy_function()  # type: ignore[no-untyped-call]

For functions you can’t type yet, use Any:

from typing import Any

def legacy_processor(data: Any) -> Any:
    # Complex logic without types
    return process(data)

When working with untyped third-party libraries, install type stubs if available:

pip install types-requests
pip install types-PyYAML

Create your own stub files for libraries without stubs. For a package mylib, create mylib.pyi:

def some_function(x: int) -> str: ...

class SomeClass:
    def method(self) -> None: ...

Run mypy incrementally as you add types:

mypy --incremental src/

This caches results and only checks changed files, making type checking faster on large projects.

Common Pitfalls

Mutable default arguments: Don’t use mutable defaults with type hints:

# Wrong
def append_to_list(item: int, items: list[int] = []) -> list[int]:
    items.append(item)
    return items

# Correct
def append_to_list(item: int, items: list[int] | None = None) -> list[int]:
    if items is None:
        items = []
    items.append(item)
    return items

Forgetting Optional: If a parameter can be None, use Optional:

# Wrong
def greet(name: str) -> str:
    if name is None:
        return "Hello, stranger!"
    return f"Hello, {name}!"

# Correct
def greet(name: str | None) -> str:
    if name is None:
        return "Hello, stranger!"
    return f"Hello, {name}!"

Using Any too much: Overusing Any defeats the purpose of type hints. Be specific when possible:

# Weak
def process(data: Any) -> Any:
    return data.upper()

# Better
def process(data: str) -> str:
    return data.upper()

Circular imports: Type hints can cause circular import issues. Use string literals for forward references:

# models.py
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from .other import OtherClass

class MyClass:
    def method(self, other: "OtherClass") -> None:
        pass

Or use from __future__ import annotations (Python 3.7+):

from __future__ import annotations

class Node:
    def __init__(self, value: int, next: Node | None = None) -> None:
        self.value = value
        self.next = next

Ignoring compatibility: Type hints don’t affect runtime. Don’t rely on them for validation:

def divide(a: int, b: int) -> float:
    return a / b

# This runs fine at runtime, even though type is wrong
result = divide("10", "2")  # Runtime error later

Use runtime validation for user input:

def divide(a: int, b: int) -> float:
    if not isinstance(a, int) or not isinstance(b, int):
        raise TypeError("Both arguments must be integers")
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

Summary

Type hints and mypy help you catch bugs earlier and make your code easier to understand. Start by adding types to new code, then gradually expand coverage. Use mypy’s configuration options to match your project’s needs.

Key takeaways:

  • Basic types (int, str, bool) catch common mistakes
  • Generic types (list, dict) specify container contents
  • Optional and Union handle values that can be multiple types
  • Protocols enable structural typing without inheritance
  • Configuration files customize mypy’s strictness
  • Gradual typing lets you adopt types incrementally

The time you invest in adding type hints pays off through fewer bugs, better documentation, and easier refactoring. When something breaks six months from now, the type annotations will point you straight to the problem.

Spread The Article

Share this guide

Send this article to your network or keep a copy of the direct link.

X Facebook LinkedIn Reddit Telegram

Discussion

Leave a comment

No comments yet

Be the first to start the conversation.