In the previous tutorial, we learned about context managers. Now let’s take a deep dive into type hints — annotations that make your code safer, more readable, and easier to refactor.
We have been using basic type hints since Tutorial #3. Now it is time to learn the advanced types: Optional, Union, Literal, Annotated, TypeAlias, Callable, and more. By the end of this tutorial, you will know how to annotate any Python code and use tools like mypy to catch bugs before runtime.
Why Type Hints?
Type hints serve three purposes:
- Documentation — they tell readers what a function expects and returns. No need to read the function body to understand the interface.
- IDE support — VS Code and PyCharm use them for autocomplete, refactoring, and inline error detection. This makes you faster.
- Static analysis — tools like mypy catch type errors before you run the code. You find bugs at development time, not in production.
Python ignores type hints at runtime. They have zero performance cost. They are purely for developers and tools. You can add them gradually — you do not need to annotate every file at once.
Basic Type Hints Review
You already know these from earlier tutorials:
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
def greet(name: str, greeting: str = "Hello") -> str:
"""Return a greeting message."""
return f"{greeting}, {name}!"
def is_even(n: int) -> bool:
"""Check if a number is even."""
return n % 2 == 0
The syntax: parameter: type for inputs, -> type for the return value. Default values come after the type hint: greeting: str = "Hello".
Collection Types
Since Python 3.9, you can use built-in collection types directly in type hints:
def sum_list(numbers: list[int]) -> int:
"""Sum a list of integers."""
return sum(numbers)
def merge_dicts(a: dict[str, int], b: dict[str, int]) -> dict[str, int]:
"""Merge two dictionaries."""
return {**a, **b}
def unique_items(items: list[str]) -> set[str]:
"""Return unique items from a list."""
return set(items)
def first_and_last(items: list[str]) -> tuple[str, str]:
"""Return the first and last items."""
return items[0], items[-1]
Here is a reference of collection types:
| Type hint | Meaning |
|---|---|
list[int] | List of integers |
dict[str, int] | Dict with string keys and int values |
set[str] | Set of strings |
tuple[str, int] | Tuple with exactly a string and an int |
tuple[int, ...] | Tuple of any number of ints |
list[dict[str, int]] | List of dicts |
Before Python 3.9, you had to import these from typing: from typing import List, Dict, Set, Tuple. The modern syntax (lowercase, no import) is preferred.
Optional and Union
Optional: Value or None
Optional[str] means “string or None”. Use it when a function might return nothing:
from typing import Optional
def find_user(user_id: int, users: dict[int, str]) -> Optional[str]:
"""Find a user by ID. Returns None if not found."""
return users.get(user_id)
result = find_user(1, {1: "Alex", 2: "Sam"}) # "Alex"
result = find_user(99, {1: "Alex", 2: "Sam"}) # None
Optional[str] is equivalent to str | None in Python 3.10+.
Union: Multiple Possible Types
Union[int, float, str] means “any of these types”:
from typing import Union
def parse_value(value: str) -> Union[int, float, str]:
"""Parse a string to int, float, or return as string."""
try:
return int(value)
except ValueError:
pass
try:
return float(value)
except ValueError:
return value
print(parse_value("42")) # 42 (int)
print(parse_value("3.14")) # 3.14 (float)
print(parse_value("hello")) # "hello" (str)
Modern Union Syntax (Python 3.10+)
Use | instead of Union and Optional:
# Modern syntax — clean and readable
def process_input(data: str | int | None) -> str:
if data is None:
return "No data"
return str(data)
def find_user(user_id: int) -> str | None:
...
The | syntax is recommended for Python 3.10+. It is shorter and more readable.
Literal: Restrict to Specific Values
Literal restricts a parameter to specific values. This is like an enum but simpler:
from typing import Literal
def set_color(color: Literal["red", "green", "blue"]) -> str:
"""Set a color. Only accepts 'red', 'green', or 'blue'."""
return f"Color set to {color}"
def set_log_level(level: Literal["debug", "info", "warning", "error"]) -> str:
"""Set the log level."""
return f"Log level: {level}"
IDEs will autocomplete the valid options and warn if you pass an invalid value. mypy will also catch errors:
set_color("red") # OK
set_color("purple") # mypy error: Argument has incompatible type "str"
Literal is useful for function parameters that accept a fixed set of string values, like configuration options, modes, or status codes.
TypeAlias: Name Your Types
Create meaningful names for complex types. This makes your code more readable:
from typing import TypeAlias
UserId: TypeAlias = int
Username: TypeAlias = str
UserMap: TypeAlias = dict[UserId, Username]
def get_username(user_id: UserId, users: UserMap) -> Username | None:
"""Get a username by ID."""
return users.get(user_id)
Instead of seeing dict[int, str] everywhere, you see UserMap — which tells you what the dictionary represents. Type aliases are especially useful for deeply nested types:
# Without alias — hard to read
def process(data: dict[str, list[tuple[int, str]]]) -> None: ...
# With alias — clear
Record: TypeAlias = tuple[int, str]
DataSet: TypeAlias = dict[str, list[Record]]
def process(data: DataSet) -> None: ...
Python 3.12 introduced a cleaner built-in type statement that replaces TypeAlias. It requires no import and is more explicit:
# Python 3.12+ — built-in type alias syntax
type UserId = int
type Matrix = list[list[float]]
The type statement is cleaner than TypeAlias because it is a dedicated keyword that makes the alias visible to type checkers and IDEs without any import. If you are on Python 3.12+, prefer type over TypeAlias.
Annotated: Add Metadata to Types
Annotated adds metadata to a type hint. Python ignores the metadata, but frameworks like Pydantic and FastAPI use it for validation:
from typing import Annotated, Any
def create_user(
name: Annotated[str, "Must be non-empty"],
age: Annotated[int, "Must be 0-150"],
) -> dict[str, Any]:
if not name.strip():
raise ValueError("Name must be non-empty.")
if age < 0 or age > 150:
raise ValueError("Age must be between 0 and 150.")
return {"name": name.strip(), "age": age}
In FastAPI, Annotated adds validation and documentation automatically:
# FastAPI example
from fastapi import Query
@app.get("/users")
def get_users(
page: Annotated[int, Query(ge=1)] = 1,
limit: Annotated[int, Query(ge=1, le=100)] = 10,
):
...
The Query(ge=1, le=100) is metadata that FastAPI uses to validate query parameters and generate API documentation. The type itself (int) is separate from the constraints.
Callable: Type-Hint Functions
Use Callable to type functions that take other functions as arguments:
from typing import Callable
def apply_operation(
values: list[int],
operation: Callable[[int], int],
) -> list[int]:
"""Apply an operation to each value."""
return [operation(v) for v in values]
def apply_filter(
values: list[int],
predicate: Callable[[int], bool],
) -> list[int]:
"""Filter values using a predicate function."""
return [v for v in values if predicate(v)]
The syntax is Callable[[argument_types], return_type]:
Callable[[int], int]— takes one int, returns an intCallable[[str, int], bool]— takes a string and int, returns a boolCallable[[], None]— takes nothing, returns nothingCallable[..., int]— takes anything, returns an int
doubled = apply_operation([1, 2, 3], lambda x: x * 2)
# [2, 4, 6]
evens = apply_filter([1, 2, 3, 4, 5], lambda x: x % 2 == 0)
# [2, 4]
TYPE_CHECKING: Avoid Circular Imports
Sometimes you need to import a type only for type hints, not at runtime. This happens when two modules import each other. Use TYPE_CHECKING:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path # Only imported during type checking
def format_path(path_str: str) -> str:
"""Format a path string."""
return path_str.replace("\\", "/")
The TYPE_CHECKING constant is False at runtime and True when type checkers (mypy) analyze the code. This means the import only happens during static analysis.
Combine with from __future__ import annotations to make all annotations lazy (evaluated as strings, not at runtime):
from __future__ import annotations
# Now all type hints are strings — not evaluated at runtime
# This avoids issues with forward references and circular imports
Dataclass with Type Hints
Type hints work naturally with dataclasses and provide excellent IDE support:
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
tags: list[str]
category: Literal["electronics", "clothing", "food"]
discount: float | None = None
def final_price(self) -> float:
if self.discount is not None:
return self.price * (1 - self.discount)
return self.price
With these hints, your IDE knows the type of every field and method return value. Autocomplete works perfectly.
mypy: Static Type Checking
mypy is the most popular static type checker for Python. It analyzes your code without running it:
pip install mypy
mypy src/py16_type_hints.py
mypy catches errors like these:
def add(a: int, b: int) -> int:
return a + b
result = add("hello", "world")
# mypy error: Argument 1 to "add" has incompatible type "str"; expected "int"
def get_name(user: dict[str, str]) -> str:
return user["name"]
name: int = get_name({"name": "Alex"})
# mypy error: Incompatible types in assignment (expression has type "str", variable has type "int")
mypy Configuration
Add mypy settings to pyproject.toml:
[tool.mypy]
python_version = "3.13"
strict = true
warn_return_any = true
warn_unused_configs = true
Strict mode enables all checks. For existing projects, start without strict mode and enable checks gradually:
[tool.mypy]
python_version = "3.13"
check_untyped_defs = true
disallow_untyped_defs = false # Enable later
When to Use Type Hints
| Situation | Recommendation |
|---|---|
| Public functions/methods | Always — other code depends on them |
| Function parameters and returns | Always |
| Class attributes | Always (use dataclass) |
| Local variables | Only when not obvious |
| Small scripts | Optional |
| Libraries and packages | Always — users depend on them |
Start simple. Add hints to function signatures first. You do not need to annotate every variable — Python and mypy can infer most local variable types:
# No annotation needed — mypy infers 'name' is str
name = greet("Alex")
# Annotation helpful when not obvious
items: list[str] = []
config: dict[str, Any] = load_config()
Common Patterns
# Function that returns nothing
def log(message: str) -> None:
print(message)
# Function that accepts any type
from typing import Any
def debug(value: Any) -> str:
return repr(value)
# Function with *args and **kwargs
def my_func(*args: int, **kwargs: str) -> None:
...
# Class method returning the class itself
class Builder:
def set_name(self, name: str) -> "Builder":
self.name = name
return self
Gradual Typing: Adding Hints to Existing Code
You do not need to add type hints to your entire codebase at once. The recommended approach is:
- Start with new code — add hints to all new functions and classes
- Annotate public APIs — functions and methods that other code calls
- Run mypy with basic settings — catch the easy errors first
- Gradually increase strictness — enable more mypy checks over time
# Start with this (lenient)
[tool.mypy]
python_version = "3.13"
check_untyped_defs = true
# Later, enable these one by one
disallow_untyped_defs = true
warn_return_any = true
strict = true
Many large Python projects took years to add full type coverage. Python’s type system is designed for gradual adoption — you get benefits even with partial coverage.
Type Hints vs Runtime Validation
Type hints are checked statically (by mypy). They do not enforce types at runtime:
def add(a: int, b: int) -> int:
return a + b
# This runs fine! Python ignores type hints at runtime.
result = add("hello", "world") # Returns "helloworld"
# But mypy catches this: error: Argument 1 has incompatible type "str"
If you need runtime validation, use Pydantic (Tutorial #10) or manual checks. Type hints and Pydantic complement each other: hints document the interface, Pydantic enforces it at the boundary.
Source Code
You can find the code for this tutorial on GitHub:
kemalcodes/python-tutorial — tutorial-16-type-hints
Run the examples:
python src/py16_type_hints.py
Run the tests:
python -m pytest tests/test_py16.py -v
What’s Next?
In the next tutorial, we will learn about testing with pytest — how to write tests that catch bugs and make refactoring safe.
Related Articles
- Python Tutorial #15: Context Managers — with statement and resources
- Python Tutorial #10: Dataclasses and Pydantic — type hints in data models
- Python Cheat Sheet — quick reference for Python syntax