Intermediate
You write a function that takes a user dictionary and returns their email. It works perfectly — until three months later when someone passes a list of users instead of a single user, and the function silently returns garbage instead of crashing with a helpful error. This is the kind of bug that type hints prevent. They let you declare exactly what types your functions expect and return, so tools like mypy can catch mistakes before your code ever runs.
The best part is that type hints are built into Python 3.5+ and require zero extra installation for basic use. They are completely optional — Python does not enforce them at runtime — but they serve as living documentation that IDEs and type checkers can validate automatically. If you use VS Code or PyCharm, you are already getting type hint benefits through autocomplete and inline error detection.
In this article we will start with a quick example so you can see the value immediately. Then we will cover basic type hint syntax for variables and functions, collection types like lists and dictionaries, Optional and Union types for flexible parameters, type hints for classes, and how to run mypy to catch type errors statically. We will finish with a real-life project that refactors untyped code into a fully type-safe inventory management system.
Python Type Hints: Quick Example
Here is a simple function with type hints that calculates a discounted price. The hints tell you (and your tools) exactly what goes in and what comes out, with no guessing required.
# quick_example.py
def calculate_total(price: float, quantity: int, discount: float = 0.0) -> float:
"""Calculate the total cost after applying a discount."""
subtotal: float = price * quantity
total: float = subtotal * (1 - discount)
return round(total, 2)
# Correct usage
print(calculate_total(29.99, 3))
print(calculate_total(49.99, 2, discount=0.15))
Output:
89.97
84.98
The price: float annotation says the first argument should be a float, quantity: int says the second should be an integer, and -> float after the parentheses says the function returns a float. If someone tries to call calculate_total("free", 3), a type checker like mypy will flag it as an error before the code runs. Notice that Python itself will not raise an error — type hints are advisory, not enforced — but the tooling catches it for you.
Want to go deeper? Below we cover every type hint pattern you will need in real projects, from basic annotations to generics and TypedDict.
What Are Type Hints and Why Use Them?
Type hints (also called type annotations) are a way to declare the expected types of variables, function parameters, and return values in Python. They were introduced in PEP 484 (Python 3.5) and have been expanded in every Python release since. Think of them as labels on boxes — the label says “contains integers” but Python does not actually check whether you put strings in the box. External tools like mypy, pyright, and your IDE do the checking for you.
Here is why type hints matter in practice:
| Benefit | Without Type Hints | With Type Hints |
|---|---|---|
| Reading code | Guess what data contains from context | data: dict[str, list[int]] tells you exactly |
| IDE support | Limited autocomplete, no inline errors | Full autocomplete, real-time error detection |
| Bug detection | Bugs found at runtime (or in production) | Bugs caught before code runs via mypy |
| Refactoring | Change a function signature, hope nothing breaks | mypy shows every caller that needs updating |
| Documentation | Write docstrings that go stale | Types are always accurate (enforced by tools) |
Type hints do not affect performance — Python ignores them at runtime. They also do not make Python a statically typed language. You can still write untyped code, and typed and untyped code can coexist in the same project. The value comes from the tooling ecosystem that reads and validates your annotations. Let us start with the basic syntax.
Basic Type Hint Syntax
The fundamental pattern is simple: add a colon and a type after variable names or parameters, and use -> to annotate return types. Here are the most common basic types you will use every day.
# basic_types.py
# Variable annotations
name: str = "Alice"
age: int = 30
price: float = 19.99
is_active: bool = True
# Function with type hints
def greet(name: str, excited: bool = False) -> str:
"""Return a greeting message."""
if excited:
return f"Hello, {name}! Welcome!"
return f"Hello, {name}."
# Function that returns nothing
def log_message(message: str) -> None:
"""Print a log message. Returns nothing."""
print(f"[LOG] {message}")
# Test the functions
print(greet("Bob"))
print(greet("Charlie", excited=True))
log_message("Server started")
Output:
Hello, Bob.
Hello, Charlie! Welcome!
[LOG] Server started
The four basic types — str, int, float, and bool — cover most simple cases. Use -> None for functions that do not return a value (like functions that only print or write to a file). Default values work normally alongside type hints — excited: bool = False means the parameter is a boolean that defaults to False. One important note: int is compatible with float in type checking, so a function annotated with float will accept integers without complaint.

Collection Types
Real code rarely works with single values — you pass lists, dictionaries, sets, and tuples everywhere. Type hints for collections tell you not just that something is a list, but what the list contains. Since Python 3.9, you can use the built-in collection types directly (lowercase list, dict, set, tuple). For Python 3.8 and earlier, import the capitalized versions from the typing module.
# collection_types.py
# Lists — specify what's inside
names: list[str] = ["Alice", "Bob", "Charlie"]
scores: list[int] = [95, 87, 92, 78]
# Dictionaries — specify key and value types
user_ages: dict[str, int] = {"Alice": 30, "Bob": 25}
config: dict[str, str | int | bool] = {
"host": "localhost",
"port": 8080,
"debug": True
}
# Sets — specify element type
unique_tags: set[str] = {"python", "tutorial", "beginner"}
# Tuples — specify each position's type
coordinates: tuple[float, float] = (40.7128, -74.0060)
rgb_color: tuple[int, int, int] = (255, 128, 0)
# Function using collection types
def get_top_students(
grades: dict[str, float],
threshold: float = 90.0
) -> list[str]:
"""Return names of students scoring above the threshold."""
return [name for name, grade in grades.items() if grade >= threshold]
# Test
class_grades = {"Alice": 95.5, "Bob": 82.0, "Charlie": 91.3, "Diana": 88.7}
top = get_top_students(class_grades)
print(f"Top students: {top}")
Output:
Top students: ['Alice', 'Charlie']
The key insight is that list[str] is much more informative than just list. When your IDE sees list[str], it knows that iterating over the list yields strings, so it can offer string methods in autocomplete. The dict[str, float] annotation tells tools that keys are strings and values are floats — so grades["Alice"] is a float and grades.keys() returns strings. For tuples, you specify the type of each position because tuples are often used as fixed-size records (like coordinates or RGB values). If you want a variable-length tuple of one type, use tuple[int, ...] with an ellipsis.
Optional and Union Types
Sometimes a value can be one of several types, or it might be None. Python 3.10 introduced the | (pipe) operator for union types, which is the cleanest syntax. For earlier versions, use Union and Optional from the typing module.
# optional_union.py
from typing import Optional
# Union type: can be str or int (Python 3.10+ syntax)
user_id: str | int = "user_123"
user_id = 456 # Also valid
# Optional: can be the type or None
# Optional[str] is exactly the same as str | None
middle_name: Optional[str] = None
def find_user(user_id: str | int) -> dict[str, str] | None:
"""Look up a user by string or numeric ID. Returns None if not found."""
users = {
"user_123": {"name": "Alice", "email": "alice@mail.com"},
456: {"name": "Bob", "email": "bob@mail.com"},
}
return users.get(user_id)
def format_name(first: str, last: str, middle: Optional[str] = None) -> str:
"""Format a full name, optionally including a middle name."""
if middle:
return f"{first} {middle} {last}"
return f"{first} {last}"
# Test
print(find_user("user_123"))
print(find_user(999))
print(format_name("John", "Doe"))
print(format_name("John", "Doe", middle="Michael"))
Output:
{'name': 'Alice', 'email': 'alice@mail.com'}
None
John Doe
John Michael Doe
The str | int syntax means the value can be either a string or an integer. The Optional[str] type is shorthand for str | None — use it when a parameter or return value might be None. This is one of the most important patterns in type-hinted code because None is the source of countless AttributeError exceptions. When mypy sees that find_user returns dict | None, it will force you to check for None before accessing dictionary keys — catching potential crashes at analysis time instead of runtime.

Type Hints for Classes
Type hints work seamlessly with your own classes. You annotate instance attributes, method parameters, and return types just like regular functions. The dataclasses module makes this especially clean because it uses type hints as the primary way to define fields.
# class_types.py
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Product:
"""A product in an inventory system."""
name: str
price: float
quantity: int
category: str = "General"
def total_value(self) -> float:
"""Calculate the total inventory value for this product."""
return round(self.price * self.quantity, 2)
def apply_discount(self, percent: float) -> float:
"""Return the discounted price without modifying the original."""
return round(self.price * (1 - percent / 100), 2)
@dataclass
class Order:
"""A customer order containing multiple products."""
order_id: str
items: list[Product]
created_at: datetime
def grand_total(self) -> float:
"""Calculate the total cost of all items in the order."""
return round(sum(item.price * item.quantity for item in self.items), 2)
def item_count(self) -> int:
"""Return the total number of items across all products."""
return sum(item.quantity for item in self.items)
# Test
laptop = Product("Laptop", 999.99, 5, "Electronics")
mouse = Product("Mouse", 29.99, 20)
order = Order(
order_id="ORD-001",
items=[laptop, mouse],
created_at=datetime.now()
)
print(f"Laptop value: ${laptop.total_value()}")
print(f"Laptop at 10% off: ${laptop.apply_discount(10)}")
print(f"Order total: ${order.grand_total()}")
print(f"Order items: {order.item_count()}")
Output:
Laptop value: $4999.95
Laptop at 10% off: $899.99
Order total: $5599.75
Order items: 25
The @dataclass decorator reads your type annotations and automatically generates __init__, __repr__, and __eq__ methods. This means the type hints are not just documentation — they directly define your class structure. When you annotate items: list[Product] on the Order class, your IDE knows that iterating over self.items yields Product objects, so it can autocomplete item.price, item.quantity, and all other Product attributes. Without the type hint, your IDE would have no idea what item is inside the loop.
Running mypy to Catch Type Errors
Type hints only reach their full potential when paired with a type checker. mypy is the most popular one — it reads your annotations and reports any inconsistencies without running your code. Install it with pip install mypy, then run it on your Python files.
# mypy_demo.py
# Save this file and run: mypy mypy_demo.py
def add_numbers(a: int, b: int) -> int:
"""Add two integers together."""
return a + b
def get_username(user: dict[str, str]) -> str:
"""Extract the username from a user dictionary."""
return user["username"]
# These lines have type errors that mypy will catch:
result = add_numbers(10, "20") # Error: str is not int
total = add_numbers(10, 20) + "!" # Error: can't add int and str
name = get_username(["Alice"]) # Error: list is not dict
Output (from running mypy mypy_demo.py):
mypy_demo.py:12: error: Argument 2 to "add_numbers" has incompatible type "str"; expected "int" [arg-type]
mypy_demo.py:13: error: Unsupported operand types for + ("int" and "str") [operator]
mypy_demo.py:14: error: Argument 1 to "get_username" has incompatible type "list[str]"; expected "dict[str, str]" [arg-type]
Found 3 errors in 1 file (checked 1 source file)
Every error message includes the file name, line number, a clear explanation of what is wrong, and the error code in brackets. The [arg-type] errors mean you passed the wrong type to a function parameter, and [operator] means you used an operator with incompatible types. These are bugs that would have crashed at runtime — mypy catches them instantly. You can add mypy to your CI/CD pipeline so that type errors block pull requests, or configure your IDE to run it on every save. For large existing codebases, you can adopt type hints gradually — mypy only checks files that have annotations and ignores untyped code by default.

Real-Life Example: Type-Safe Inventory System

Let us build a practical project that shows how type hints improve a real codebase. This inventory management system tracks products, processes orders, and generates reports — all with complete type safety. Every function clearly declares what it expects and returns, so mypy can verify the entire system is consistent.
# inventory_system.py
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
@dataclass
class Product:
sku: str
name: str
price: float
stock: int
category: str
@dataclass
class OrderItem:
product: Product
quantity: int
def subtotal(self) -> float:
return round(self.product.price * self.quantity, 2)
@dataclass
class Inventory:
products: dict[str, Product] = field(default_factory=dict)
order_history: list[list[OrderItem]] = field(default_factory=list)
def add_product(self, product: Product) -> None:
self.products[product.sku] = product
def find_product(self, sku: str) -> Optional[Product]:
return self.products.get(sku)
def process_order(self, items: list[OrderItem]) -> float | None:
# Verify stock for all items first
for item in items:
if item.product.stock < item.quantity:
print(f"Insufficient stock for {item.product.name}")
return None
# Deduct stock and calculate total
total: float = 0.0
for item in items:
item.product.stock -= item.quantity
total += item.subtotal()
self.order_history.append(items)
return round(total, 2)
def low_stock_report(self, threshold: int = 10) -> list[tuple[str, str, int]]:
return [
(p.sku, p.name, p.stock)
for p in self.products.values()
if p.stock <= threshold
]
def sales_summary(self) -> dict[str, float]:
summary: dict[str, float] = {}
for order in self.order_history:
for item in order:
category = item.product.category
summary[category] = summary.get(category, 0.0) + item.subtotal()
return {k: round(v, 2) for k, v in summary.items()}
# --- Demo ---
inv = Inventory()
inv.add_product(Product("LAP-001", "Laptop Pro", 1299.99, 15, "Electronics"))
inv.add_product(Product("MOU-001", "Wireless Mouse", 24.99, 50, "Accessories"))
inv.add_product(Product("KEY-001", "Mechanical Keyboard", 89.99, 8, "Accessories"))
inv.add_product(Product("MON-001", "4K Monitor", 449.99, 3, "Electronics"))
# Process an order
laptop = inv.find_product("LAP-001")
mouse = inv.find_product("MOU-001")
if laptop and mouse:
order_items = [OrderItem(laptop, 2), OrderItem(mouse, 5)]
total = inv.process_order(order_items)
print(f"Order total: ${total}")
# Low stock report
print(f"\nLow stock items:")
for sku, name, stock in inv.low_stock_report():
print(f" {sku}: {name} ({stock} remaining)")
# Sales summary
print(f"\nSales by category: {inv.sales_summary()}")
Output:
Order total: $2724.93
Low stock items:
KEY-001: Mechanical Keyboard (8 remaining)
MON-001: 4K Monitor (3 remaining)
Sales by category: {'Electronics': 2599.98, 'Accessories': 124.95}
This project demonstrates several important type hint patterns working together. The find_product method returns Optional[Product], which forces callers to check for None before using the result — notice the if laptop and mouse: guard before processing the order. The process_order method returns float | None, signaling that it can fail (returning None when stock is insufficient). The low_stock_report returns list[tuple[str, str, int]], which lets you unpack each tuple directly in the for loop. To extend this project, try adding a TypedDict for JSON export, a generic Repository[T] class for database abstraction, or Protocol types for duck-typing interfaces.
Frequently Asked Questions
Do type hints slow down Python or enforce types at runtime?
No to both. Python completely ignores type hints at runtime — they have zero performance impact. The annotations are stored as metadata on functions and classes but never checked during execution. If you pass a string where an int is expected, Python will happily try to use it (and probably crash with a TypeError). The enforcement comes from external tools like mypy and pyright that analyze your code statically, before it runs.
Can I add type hints to an existing project gradually?
Absolutely — this is the recommended approach. Start by adding type hints to your most important functions (public APIs, data processing pipelines, anything that handles external input). You can configure mypy with --ignore-missing-imports and --allow-untyped-defs to suppress errors in untyped code. Over time, tighten the configuration as you add more annotations. Many teams use a py.typed marker file to indicate packages that are fully typed.
What does the Any type do?
The Any type from the typing module is an escape hatch that disables type checking for a specific value. A variable of type Any can hold anything, and mypy will not complain about how you use it. Use it sparingly — it defeats the purpose of type hints. Common legitimate uses include wrapping third-party libraries that do not have type stubs, or annotating truly dynamic data (like JSON parsed from an unknown API). Prefer more specific types whenever possible.
Should I use typing.List or list for type hints?
Use the lowercase built-in types (list, dict, set, tuple) if your project targets Python 3.9 or higher. The uppercase versions from the typing module (List, Dict, Set, Tuple) are the older syntax needed for Python 3.8 and below. They behave identically — the only difference is syntax. If you need to support older Python versions, you can use from __future__ import annotations at the top of your file to enable the newer syntax everywhere.
What is the difference between Protocol and abstract base classes?
A Protocol (from typing) defines structural subtyping — also called duck typing. Any class that has the right methods matches the protocol, even if it does not explicitly inherit from it. Abstract base classes (ABCs) use nominal subtyping — a class must explicitly inherit from the ABC to be considered a match. Use Protocol when you want flexibility (any class with a .read() method), and use ABCs when you want to enforce an explicit inheritance hierarchy.
Should I use mypy or pyright for type checking?
Both are excellent. mypy is the original Python type checker, maintained by the core typing team, and has the broadest ecosystem support. pyright is Microsoft’s type checker, built in TypeScript, and is faster — it powers Pylance in VS Code. If you use VS Code, you are probably already running pyright through Pylance. For CI/CD pipelines, mypy is more common. You can run both — they occasionally catch different issues since they implement slightly different interpretations of the typing spec.
Conclusion
In this article we covered Python type hints from the ground up. We started with basic annotations for variables and function signatures using str, int, float, and bool. We then moved to collection types (list[str], dict[str, int], tuple[float, float]), Optional and Union types for handling None and multiple-type parameters, type hints for classes and dataclasses, and running mypy to catch errors statically. The inventory system project showed how all these patterns work together in a real codebase.
Type hints are one of the highest-impact improvements you can make to any Python project. Start by annotating your most critical functions, run mypy to catch existing bugs, and gradually expand coverage. The investment pays off immediately in better IDE support and catches bugs that would otherwise reach production.
For the complete reference on Python’s type system, see the official typing module documentation and the mypy documentation.