Intermediate

How To Use Type Hints in Python with Mypy

Python is known for its simplicity and readability, but this comes at a cost — it’s dynamically typed, which means you can assign any type of value to a variable at any time. While this flexibility is powerful, it can lead to subtle bugs that only appear at runtime. Imagine debugging a function that crashes because you accidentally passed a string where an integer was expected, only to discover the issue after hours of investigation. Type hints solve this problem by letting you specify what types your functions and variables should accept, catching errors before your code ever runs.

If you’re worried that adding type hints will make your Python code feel like Java or C++, rest assured — Python’s type hints are optional, unobtrusive, and entirely optional at runtime. They exist purely for documentation, IDE support, and static analysis. Your code runs exactly the same with or without them, but with type hints, you unlock powerful tools like Mypy that can catch entire categories of bugs before deployment.

In this tutorial, you’ll learn everything you need to start using type hints effectively. We’ll cover the basics of annotating variables and functions, explore the typing module, understand how Mypy validates your code, and work through real-world examples that demonstrate the power of static type checking. By the end, you’ll understand why type hints are becoming standard practice in professional Python codebases.

Quick Example

Here’s a minimal example that shows type hints in action. Don’t worry if it looks unfamiliar — we’ll break down each component in detail:

# file: quick_example.py
def greet(name: str, age: int) -> str:
    return f"Hello {name}, you are {age} years old"

def add_numbers(a: int, b: int) -> int:
    return a + b

result = add_numbers(5, 10)
message = greet("Alice", 30)
print(message)
print(result)

Without type hints, Python wouldn’t catch it if you accidentally called `add_numbers(“5”, 10)` with a string instead of an integer. With type hints and Mypy, this error is caught instantly before you run the code.

What Are Type Hints and Why Use Them?

Type hints are annotations that specify what types your functions, variables, and return values should be. They’re written using Python’s typing syntax and don’t affect how your code executes — Python’s interpreter completely ignores them at runtime. Their purpose is to help you, your team, and automated tools understand what types of data flow through your code.

The primary benefits of type hints include catching bugs before runtime, improving code readability, enabling better IDE autocompletion, serving as documentation, and refactoring with confidence. When you annotate your code with types, tools like Mypy can analyze it statically and warn you about potential type mismatches without executing the code.

Let’s compare typed versus untyped code side by side:

Without Type Hints With Type Hints
def process(data): def process(data: list) -> int:
Unclear what types are expected Clear intent and expectations
IDE can’t provide smart autocomplete IDE knows what methods are available
Runtime errors when wrong types passed Static analysis catches type errors
Hard to refactor safely Mypy ensures refactoring doesn’t break contracts

Type hints scale tremendously in larger codebases. A function with type hints serves as a contract — callers know exactly what to pass, and the function author knows exactly what to expect. This reduces bugs, improves collaboration, and makes code easier to maintain.

Basic Type Hints: Built-in Types

Let’s start with the simplest type hints — annotations for built-in Python types. You can annotate variables when you create them, and annotate function parameters and return values.

# file: basic_types.py
# Simple variable annotations
name: str = "Alice"
age: int = 30
height: float = 5.8
is_active: bool = True

# Function with type hints
def calculate_age_in_days(age: int) -> int:
    return age * 365

def greet_user(name: str, age: int) -> str:
    days = calculate_age_in_days(age)
    return f"{name} is {days} days old"

# Using the functions
print(greet_user("Bob", 25))
print(calculate_age_in_days(40))

Output:

Bob is 9125 days old
14600

In the example above, the colon (:) separates the parameter name from its type. For return types, the arrow (->) comes after the parameter list. These annotations tell anyone reading the code — and tools like Mypy — exactly what types are expected. If you tried to call `calculate_age_in_days(“thirty”)`, Mypy would immediately flag it as an error.

The basic types you’ll use most often are `str`, `int`, `float`, and `bool`. But what if you need to work with collections like lists or dictionaries? That’s where things get interesting.

Collections: Lists, Dicts, and Tuples

When you want to annotate a list, you can’t just write `list` — you need to specify what type of items the list contains. This is where the `typing` module comes in. The `typing` module provides generic types like `List`, `Dict`, and `Tuple` that let you specify what they contain.

# file: collections_example.py
from typing import List, Dict, Tuple

# List of integers
scores: List[int] = [95, 87, 92, 88]

# List of strings
names: List[str] = ["Alice", "Bob", "Charlie"]

# Dictionary with string keys and integer values
age_map: Dict[str, int] = {"Alice": 30, "Bob": 25, "Charlie": 28}

# Tuple with fixed types
location: Tuple[float, float] = (40.7128, -74.0060)

# Function that processes a list
def sum_scores(scores: List[int]) -> int:
    return sum(scores)

# Function that works with dictionaries
def get_age(person_name: str, ages: Dict[str, int]) -> int:
    return ages[person_name]

# Using the functions
total = sum_scores(scores)
alice_age = get_age("Alice", age_map)
print(f"Total scores: {total}")
print(f"Alice's age: {alice_age}")
print(f"Location: {location}")

Output:

Total scores: 362
Alice's age: 30
Location: (40.7128, -74.006)

The syntax `List[int]` means “a list containing integers”. Similarly, `Dict[str, int]` means “a dictionary with string keys and integer values”, and `Tuple[float, float]` means “a tuple containing exactly two floats”. This specificity is what makes type checking powerful — Mypy can now verify that you’re not accidentally passing a list of strings to a function expecting a list of integers.

Optional and Union Types

Sometimes a function might return either a value or `None`, or it might accept multiple different types. Python provides `Optional` and `Union` for these scenarios. `Optional[T]` is shorthand for “either a value of type T or None”, while `Union` lets you specify multiple possible types.

# file: optional_union.py
from typing import Optional, Union

# Function that might return None
def find_user_age(name: str, users: dict) -> Optional[int]:
    if name in users:
        return users[name]["age"]
    return None

# Function that accepts multiple types
def process_value(value: Union[int, str]) -> str:
    if isinstance(value, int):
        return f"Number: {value * 2}"
    else:
        return f"Text: {value.upper()}"

# Dictionary of user data
users_db = {
    "Alice": {"age": 30},
    "Bob": {"age": 25}
}

# Using Optional
age = find_user_age("Alice", users_db)
print(f"Alice's age: {age}")

missing_age = find_user_age("Charlie", users_db)
print(f"Charlie's age: {missing_age}")

# Using Union
result1 = process_value(10)
result2 = process_value("hello")
print(result1)
print(result2)

Output:

Alice's age: 30
Charlie's age: None
Number: 20
Text: HELLO

The `Optional` type is essential in Python because None is a valid value in many scenarios. By marking a return type as `Optional[int]`, you’re telling callers “this function might return an integer or None, and you should handle both cases”. This prevents a whole class of bugs where code forgets to check for None before using a value.

The Typing Module: List, Dict, Tuple, and More

We briefly introduced `List`, `Dict`, and `Tuple` from the typing module. Let’s explore more capabilities and understand when to use them. In Python 3.9+, you can actually use built-in `list`, `dict`, and `tuple` directly for type hints, but the `typing` module versions work in all Python versions and provide more features.

# file: typing_module.py
from typing import List, Dict, Set, Tuple, Any

# Specific typed collections
numbers: List[int] = [1, 2, 3, 4, 5]
name_ages: Dict[str, int] = {"Alice": 30, "Bob": 25}
unique_tags: Set[str] = {"python", "tutorial", "typing"}
coordinates: Tuple[int, int, int] = (10, 20, 30)

# Using Any when type is truly unknown (use sparingly!)
# This disables type checking for this variable
unknown_value: Any = "could be anything"

# Function with complex typing
def process_data(
    items: List[Dict[str, Any]],
    filters: Set[str]
) -> List[str]:
    results = []
    for item in items:
        if item.get("type") in filters:
            results.append(item.get("name", "Unknown"))
    return results

# Sample data
data = [
    {"name": "Alice", "type": "user"},
    {"name": "Bob", "type": "admin"},
    {"name": "Document", "type": "file"}
]

# Using the function
filtered = process_data(data, {"user", "admin"})
print(f"Filtered results: {filtered}")

Output:

Filtered results: ['Alice', 'Bob']

The `Any` type is a special case that essentially says “this can be any type” and disables type checking for that variable. Use `Any` sparingly — it defeats the purpose of type hints. It’s useful for truly dynamic situations or when working with third-party code you can’t control, but typed alternatives are almost always better.

Function Annotations: Parameters and Returns

Function annotations are where type hints shine. By annotating parameters and return types, you create a contract that documents what a function expects and what it produces. This makes functions self-documenting and enables powerful static analysis.

# file: function_annotations.py
from typing import List, Optional

def calculate_average(scores: List[float]) -> float:
    """Calculate the average of a list of scores."""
    if not scores:
        return 0.0
    return sum(scores) / len(scores)

def find_maximum(numbers: List[int]) -> Optional[int]:
    """Return the maximum number, or None if list is empty."""
    return max(numbers) if numbers else None

def format_report(
    title: str,
    items: List[str],
    show_count: bool = True
) -> str:
    """Format items into a report string."""
    report = f"=== {title} ===\n"
    for item in items:
        report += f"- {item}\n"
    if show_count:
        report += f"Total: {len(items)}"
    return report

# Using the functions
test_scores = [85.5, 90.0, 78.5, 92.0]
average = calculate_average(test_scores)
print(f"Average score: {average}")

numbers = [45, 23, 89, 12, 56]
max_num = find_maximum(numbers)
print(f"Maximum number: {max_num}")

report = format_report("Tasks", ["Write code", "Review PR", "Deploy"], show_count=True)
print(report)

Output:

Average score: 86.5
Maximum number: 89
=== Tasks ===
- Write code
- Review PR
- Deploy
Total: 3

Notice that we’re using `Optional[int]` for functions that might return None, and `List[float]` for functions that accept collections. Default parameter values (like `show_count: bool = True`) work naturally with type hints — the type annotation comes before the equals sign.

Class Annotations and Instance Variables

Type hints work wonderfully with classes. You can annotate instance variables, method parameters, and return types. This makes class structure clear and helps catch errors when using class instances.

# file: class_annotations.py
from typing import List, Optional
from datetime import datetime

class Person:
    """Represents a person with type-annotated attributes."""

    # Class-level type annotations
    name: str
    age: int
    email: Optional[str]

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

    def get_info(self) -> str:
        """Return a formatted string with person information."""
        return f"{self.name} ({self.age} years old)"

    def is_adult(self) -> bool:
        """Check if the person is an adult."""
        return self.age >= 18

class Team:
    """Represents a team of people."""

    name: str
    members: List[Person]

    def __init__(self, name: str) -> None:
        self.name = name
        self.members = []

    def add_member(self, person: Person) -> None:
        """Add a person to the team."""
        self.members.append(person)

    def get_adult_members(self) -> List[Person]:
        """Return only adult team members."""
        return [m for m in self.members if m.is_adult()]

    def member_count(self) -> int:
        """Return the number of team members."""
        return len(self.members)

# Using the classes
alice = Person("Alice", 30, "alice@example.com")
bob = Person("Bob", 17)
charlie = Person("Charlie", 25, "charlie@example.com")

team = Team("Development")
team.add_member(alice)
team.add_member(bob)
team.add_member(charlie)

print(f"Team: {team.name}")
print(f"Total members: {team.member_count()}")
print(f"Adults: {len(team.get_adult_members())}")
for member in team.members:
    print(f"  - {member.get_info()}")

Output:

Team: Development
Total members: 3
Adults: 2
  - Alice (30 years old)
  - Bob (17 years old)
  - Charlie (25 years old)

Class annotations make the structure of your objects immediately clear. Anyone reading this code knows exactly what attributes a `Person` has and what types they hold. The `__init__` method also has type hints showing what it expects and that it returns `None` (all constructors return None since they don’t return anything explicitly).

Generics Basics: Writing Flexible Type-Safe Code

Generics allow you to write functions and classes that work with multiple types while maintaining type safety. Instead of using `Any`, you can use type variables to specify “this function works with lists of any type, but the type must be consistent”.

# file: generics_example.py
from typing import TypeVar, List, Generic

# Define a type variable -- T is a placeholder for any type
T = TypeVar('T')

# Generic function that works with any type
def get_first(items: List[T]) -> T:
    """Return the first item from a list."""
    if not items:
        raise ValueError("List is empty")
    return items[0]

def reverse_list(items: List[T]) -> List[T]:
    """Return a reversed copy of the list."""
    return items[::-1]

# Generic class
class Container(Generic[T]):
    """A simple container that holds one item of any type."""

    def __init__(self, item: T) -> None:
        self.item = item

    def get_item(self) -> T:
        return self.item

    def set_item(self, item: T) -> None:
        self.item = item

# Using generic functions
int_list = [10, 20, 30, 40]
str_list = ["apple", "banana", "cherry"]

first_int = get_first(int_list)  # Type checker knows this is int
first_str = get_first(str_list)  # Type checker knows this is str

print(f"First int: {first_int}")
print(f"First string: {first_str}")
print(f"Reversed ints: {reverse_list(int_list)}")

# Using generic class
int_container = Container(42)
str_container = Container("hello")

print(f"Int container: {int_container.get_item()}")
print(f"String container: {str_container.get_item()}")

Output:

First int: 10
First string: apple
Reversed ints: [40, 30, 20, 10]
Int container: 42
String container: hello

Generics are powerful because they preserve type information. When you call `get_first(int_list)`, type checkers understand that the return value is an `int`, not just some unknown `T`. This is much safer than using `Any` and provides excellent IDE support — your editor can offer correct autocompletion based on the actual type.

Installing and Running Mypy

Mypy is a static type checker for Python that analyzes your code without running it. Installation is straightforward using pip, and running it is even simpler. Let’s set up Mypy and check our type hints.

First, install Mypy using pip:

# file: terminal
pip install mypy

Once installed, you can check a single file or an entire directory. Create a test file with some intentional type errors to see how Mypy catches them:

# file: mypy_test.py
def add_numbers(a: int, b: int) -> int:
    return a + b

# This is correct
result1 = add_numbers(5, 10)
print(result1)

# This will cause a Mypy error
result2 = add_numbers("5", 10)
print(result2)

Now run Mypy on this file:

# file: terminal
mypy mypy_test.py

Mypy will output something like:

mypy_test.py:8: error: Argument 1 to "add_numbers" has incompatible type "str"; expected "int"

This error tells you exactly where the problem is — line 8, argument 1 of the `add_numbers` call. Even though the code would run fine if `add_numbers` could handle string input, Mypy caught the type mismatch before you ran the code. For larger projects, you can run mypy on an entire directory:

# file: terminal
mypy your_project/

You can also configure Mypy’s strictness using a `mypy.ini` file. A basic configuration might look like:

# file: mypy.ini
[mypy]
python_version = 3.9
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True

The `disallow_untyped_defs = True` option enforces that every function must have type hints. This is strict but catches a lot of bugs in larger codebases.

Common Mypy Errors and How to Fix Them

Let’s explore the most common type errors you’ll encounter with Mypy and how to fix them. Understanding these patterns will help you write type-safe code quickly.

Error: Incompatible Type Assignment

This is the most common error — you’re assigning a value of the wrong type to a variable:

# file: error_incompatible.py
# WRONG: str assigned to int variable
count: int = "five"

# CORRECT: assign actual integer
count: int = 5

# WRONG: list of strings assigned to list of ints
numbers: list[int] = ["1", "2", "3"]

# CORRECT: list of integers
numbers: list[int] = [1, 2, 3]

Fix: Make sure the value type matches the annotated type. Convert types if needed:

# file: fix_incompatible.py
# Convert before assigning
count: int = int("five")  # Could raise ValueError, but type is correct
numbers: list[int] = [int(x) for x in ["1", "2", "3"]]

Error: Missing Return Type

When a function doesn’t explicitly return a value on all code paths, Mypy complains:

# file: error_missing_return.py
def check_age(age: int) -> str:
    if age >= 18:
        return "Adult"
    # Missing return statement -- what if age < 18?

# CORRECT:
def check_age(age: int) -> str:
    if age >= 18:
        return "Adult"
    else:
        return "Minor"

Fix: Ensure all code paths return a value, or change the return type to `Optional[str]` if None is acceptable:

# file: fix_missing_return.py
from typing import Optional

def check_age(age: int) -> Optional[str]:
    if age >= 18:
        return "Adult"
    return None  # Explicitly return None

Error: Argument Has Incompatible Type

You’re passing the wrong type to a function:

# file: error_argument.py
def process_list(items: list[int]) -> int:
    return sum(items)

# WRONG: passing list of strings
result = process_list(["1", "2", "3"])

# CORRECT: convert to integers first
result = process_list([1, 2, 3])

Fix: Convert the argument to the correct type or check your function call:

# file: fix_argument.py
def process_list(items: list[int]) -> int:
    return sum(items)

# Convert strings to ints
result = process_list([int(x) for x in ["1", "2", "3"]])
print(result)  # Output: 6

Error: Item Is None (Need Optional Check)

You’re accessing an attribute on something that might be None:

# file: error_none_access.py
from typing import Optional

def get_name(person: Optional[dict]) -> str:
    return person["name"]  # person could be None!

# CORRECT:
def get_name(person: Optional[dict]) -> Optional[str]:
    if person is not None:
        return person.get("name")
    return None

Fix: Always check for None before using Optional values:

# file: fix_none_access.py
from typing import Optional

def get_name(person: Optional[dict]) -> str:
    if person is None:
        return "Unknown"
    return person.get("name", "Unknown")

# Test it
result = get_name(None)
print(result)  # Output: Unknown

Error: Return Type Mismatch

Your function returns a different type than what’s annotated:

# file: error_return_mismatch.py
def calculate(value: int) -> str:
    # WRONG: returning int instead of str
    return value * 2

# CORRECT:
def calculate(value: int) -> str:
    return str(value * 2)

Fix: Ensure your return statement returns the correct type, or change the annotation:

# file: fix_return_mismatch.py
def calculate(value: int) -> str:
    result = value * 2
    return str(result)

print(calculate(5))  # Output: "10"

Real-Life Example: Type-Safe Contact Manager

Let’s bring everything together with a practical example — a contact manager application with full type hints. This demonstrates how type hints make complex code safe and maintainable:

# file: contact_manager.py
from typing import List, Optional, Dict
from datetime import datetime

class Contact:
    """Represents a single contact with full type annotations."""

    name: str
    email: str
    phone: Optional[str]
    created_at: datetime

    def __init__(self, name: str, email: str, phone: Optional[str] = None) -> None:
        if not name or not email:
            raise ValueError("Name and email are required")
        self.name = name
        self.email = email
        self.phone = phone
        self.created_at = datetime.now()

    def get_display_name(self) -> str:
        """Return formatted contact name."""
        return self.name.upper()

    def has_phone(self) -> bool:
        """Check if contact has phone number."""
        return self.phone is not None

class ContactManager:
    """Manages a collection of contacts."""

    contacts: List[Contact]

    def __init__(self) -> None:
        self.contacts = []

    def add_contact(self, contact: Contact) -> None:
        """Add a new contact to the manager."""
        self.contacts.append(contact)

    def find_by_email(self, email: str) -> Optional[Contact]:
        """Find a contact by email address."""
        for contact in self.contacts:
            if contact.email == email:
                return contact
        return None

    def find_by_name(self, name: str) -> List[Contact]:
        """Find all contacts matching a name (partial match)."""
        return [c for c in self.contacts if name.lower() in c.name.lower()]

    def get_all_with_phone(self) -> List[Contact]:
        """Return contacts that have phone numbers."""
        return [c for c in self.contacts if c.has_phone()]

    def get_contact_summary(self) -> Dict[str, int]:
        """Return summary statistics about contacts."""
        return {
            "total": len(self.contacts),
            "with_phone": len(self.get_all_with_phone()),
            "without_phone": len(self.contacts) - len(self.get_all_with_phone())
        }

    def export_emails(self) -> List[str]:
        """Export all contact emails."""
        return [c.email for c in self.contacts]

# Using the contact manager
manager = ContactManager()

# Add contacts
alice = Contact("Alice Johnson", "alice@example.com", "+1-555-0101")
bob = Contact("Bob Smith", "bob@example.com")
charlie = Contact("Charlie Brown", "charlie@example.com", "+1-555-0103")

manager.add_contact(alice)
manager.add_contact(bob)
manager.add_contact(charlie)

# Query contacts
print(f"Total contacts: {len(manager.contacts)}")
print(f"Contacts with phones: {len(manager.get_all_with_phone())}")

found = manager.find_by_email("alice@example.com")
if found:
    print(f"Found: {found.name} ({found.phone})")

johns = manager.find_by_name("john")
print(f"Contacts named 'john': {len(johns)}")

summary = manager.get_contact_summary()
print(f"Summary: {summary}")

emails = manager.export_emails()
print(f"All emails: {emails}")

Output:

Total contacts: 3
Contacts with phones: 2
Found: Alice Johnson (+1-555-0101)
Contacts named 'john': 1
Summary: {'total': 3, 'with_phone': 2, 'without_phone': 1}
All emails: ['alice@example.com', 'bob@example.com', 'charlie@example.com']

This contact manager demonstrates several key principles: every method has clear type annotations, return types are explicit (including `Optional` and `List`), class attributes are type-annotated, and the code is self-documenting. If you run Mypy on this file, it will validate that every function returns the correct type and every variable receives compatible values. This gives you confidence that the code works as intended without having to manually trace through every function call.

Best Practices for Type Hints

Now that you understand the mechanics of type hints, here are some best practices to follow in your projects. First, be consistent — if you use type hints in one file, use them throughout your project. Second, use the most specific type possible; don’t settle for `Any` when `List[int]` would work. Third, use type hints in all public functions but you can be more relaxed with private helper functions. Fourth, combine docstrings with type hints; while type hints show what types a function expects, docstrings explain what it does.

Another best practice is to use `Optional` only when None is truly an acceptable value. If a function should always return a string, don’t use `Optional[str]` just to be safe. Fifth, keep your types as simple as possible — deeply nested types like `Dict[str, List[Tuple[int, Optional[str]]]]` become hard to read. Consider breaking these into type aliases or separate functions. Finally, use tools like Mypy and pylint in your CI/CD pipeline to catch type errors automatically before code is merged.

Frequently Asked Questions

Do type hints affect performance or runtime behavior?

No, type hints are completely ignored at runtime. Python’s interpreter removes them during compilation, so they have zero impact on how fast or slow your code runs. Type hints exist purely for documentation and static analysis by tools like Mypy.

Can I use type hints with older Python versions?

Type hints were introduced in Python 3.5, so any Python 3.5+ supports basic type hints. However, some advanced features like union using the pipe operator (`int | str`) require Python 3.10+. For maximum compatibility, use the `typing` module imports like `Union[int, str]`.

What’s the difference between `List` from typing and built-in `list`?

In Python 3.9+, you can use built-in `list[int]` instead of `typing.List[int]`. They’re equivalent, but the built-in versions are preferred in newer code. The typing module versions work in older Python versions, so use those if you need to support Python 3.8 and earlier.

How strict should I be with type hints?

Start with type hints on all public functions and class methods. As your codebase grows and you become comfortable with types, increase strictness. Mypy has a `disallow_untyped_defs` option that enforces types everywhere, but it’s strict and requires more discipline. Find a balance that works for your team.

Can I type hint dictionaries with multiple value types?

Yes, use `Dict[str, Union[int, str]]` to indicate a dictionary with string keys and values that can be either int or str. You can also use `Any` if values are truly unknown, but try to be more specific when possible.

Should I use type hints in scripts and small projects?

Even small projects benefit from type hints, especially if you’ll return to them later or share them with others. Type hints serve as documentation and help you catch bugs. The investment in adding them pays off quickly.

Conclusion

Type hints are a powerful tool for writing safer, more maintainable Python code. They transform Python from a language where type errors hide until runtime into one where you catch them during development. Combined with Mypy, type hints let you refactor code with confidence, understand complex codebases faster, and collaborate more effectively with teammates.

The journey to type-safe Python starts simple with basic annotations and grows as your codebase becomes more complex. Begin by adding type hints to your public functions, run Mypy regularly, and gradually increase your type coverage. The investment in type hints pays dividends in code quality and developer productivity.

To learn more, check out the official Python typing module documentation and the Mypy documentation. Both resources provide comprehensive references and advanced patterns for type hints.