Python Type Hints: A Practical Guide for Cleaner Code
Introduction
Python is dynamically typed. You can write a function that accepts any argument and Python will not complain — until it crashes at runtime.
Type hints, introduced in Python 3.5 and significantly improved in every version since, let you annotate your code with expected types. Python itself does not enforce them at runtime — they are purely informational. But static analysis tools like mypy and editors like VS Code and PyCharm use them to catch bugs before your code ever runs.
This guide covers:
- Basic function and variable annotations
- Built-in collection types
Optional,Union, andAnyCallable,TypeVar, and generics- Dataclasses with type hints
- Running
mypyfor static analysis - When to use type hints and when to skip them
All examples are tested on Python 3.12.
Basic Annotations
Function Parameters and Return Types
def greet(name: str) -> str:
return f"Hello, {name}"
def add(x: int, y: int) -> int:
return x + y
def process(data: list, verbose: bool = False) -> None:
if verbose:
print(f"Processing {len(data)} items")
The syntax is:
parameter: typefor parameters-> typeafter the closing parenthesis for return types-> Nonefor functions that return nothing
Variable Annotations
name: str = "Alice"
age: int = 28
price: float = 9.99
active: bool = True
Variable annotations are less common in practice — Python can usually infer the type from the assignment. They are most useful for class attributes and for declaring variables before assigning them:
result: int # declared but not yet assigned
Built-in Collection Types
In Python 3.9+, you can use the built-in collection types directly as annotations:
def process_names(names: list[str]) -> list[str]:
return [n.strip().title() for n in names]
def count_words(text: str) -> dict[str, int]:
result: dict[str, int] = {}
for word in text.split():
result[word] = result.get(word, 0) + 1
return result
def unique_ids(items: list[int]) -> set[int]:
return set(items)
def get_pair(key: str) -> tuple[str, int]:
return (key, len(key))
Python 3.8 and Earlier
For Python 3.8 and earlier, import the types from typing:
from typing import List, Dict, Set, Tuple
def process_names(names: List[str]) -> List[str]:
return [n.strip().title() for n in names]
The lowercase versions (list[str], dict[str, int]) are preferred in modern Python — they are simpler and do not require an import.
Optional and Union
Optional
Optional[X] means the value can be either X or None:
from typing import Optional
def find_user(user_id: int) -> Optional[str]:
users = {1: "Alice", 2: "Bob"}
return users.get(user_id) # returns str or None
result = find_user(1)
print(result)
result = find_user(99)
print(result)
Expected output:
Alice
None
In Python 3.10+, you can use the shorter X | None syntax:
def find_user(user_id: int) -> str | None:
users = {1: "Alice", 2: "Bob"}
return users.get(user_id)
Union
Union[X, Y] means the value can be either X or Y:
from typing import Union
def format_value(value: Union[int, float, str]) -> str:
return str(value)
In Python 3.10+:
def format_value(value: int | float | str) -> str:
return str(value)
Any
Any opts out of type checking entirely. Use it sparingly:
from typing import Any
def process(data: Any) -> Any:
return data
Any is useful when interfacing with dynamically typed code, but overusing it defeats the purpose of type hints.
Callable
Use Callable to annotate functions that accept other functions as arguments:
from typing import Callable
def apply(func: Callable[[int, int], int], x: int, y: int) -> int:
return func(x, y)
def multiply(a: int, b: int) -> int:
return a * b
result = apply(multiply, 3, 4)
print(result)
Expected output:
12
The syntax Callable[[arg_types], return_type]:
Callable[[int, int], int]— takes two ints, returns an intCallable[[str], None]— takes a string, returns nothingCallable[..., int]— any arguments, returns an int
TypeVar and Generics
TypeVar lets you write functions that work with any type while preserving type information:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
# mypy knows the return type matches the input type
x: int = first([1, 2, 3]) # int
s: str = first(["a", "b", "c"]) # str
Without TypeVar, you would have to use Any and lose type information. With TypeVar, the type checker knows that first([1, 2, 3]) returns an int, not just “something”.
Constrained TypeVar
You can restrict a TypeVar to specific types:
from typing import TypeVar
Numeric = TypeVar("Numeric", int, float)
def double(x: Numeric) -> Numeric:
return x * 2
print(double(5)) # int
print(double(2.5)) # float
Type Aliases
For complex types you use repeatedly, create an alias:
from typing import TypeAlias
# Python 3.10+
Vector: TypeAlias = list[float]
Matrix: TypeAlias = list[list[float]]
UserRecord: TypeAlias = dict[str, str | int]
def dot_product(a: Vector, b: Vector) -> float:
return sum(x * y for x, y in zip(a, b))
def create_user(name: str, age: int) -> UserRecord:
return {"name": name, "age": age}
Type aliases make complex annotations readable and maintainable — change the alias definition in one place and all uses update automatically.
Dataclasses with Type Hints
Dataclasses and type hints work extremely well together:
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Employee:
name: str
age: int
department: str
salary: float
email: Optional[str] = None
skills: list[str] = field(default_factory=list)
def give_raise(self, amount: float) -> None:
self.salary += amount
def annual_salary(self) -> float:
return self.salary * 12
alice = Employee(
name="Alice",
age=28,
department="Engineering",
salary=6500.0,
skills=["Python", "SQL"]
)
alice.give_raise(500.0)
print(f"{alice.name}: ${alice.annual_salary():,.2f}/year")
Expected output:
Alice: $84,000.00/year
Dataclasses use the type annotations to generate __init__, __repr__, and __eq__ automatically. The annotations are not just documentation — they actively drive code generation.
Literal Types
Literal restricts a value to specific constants:
from typing import Literal
def set_direction(direction: Literal["north", "south", "east", "west"]) -> None:
print(f"Moving {direction}")
def set_log_level(level: Literal["DEBUG", "INFO", "WARNING", "ERROR"]) -> None:
print(f"Log level: {level}")
set_direction("north") # valid
set_direction("up") # mypy will flag this as an error
Literal is useful for functions that only accept specific string or integer values — it is more precise than annotating the parameter as just str.
Protocol — Structural Typing
Protocol lets you define interfaces based on structure rather than inheritance:
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
class Circle:
def draw(self) -> None:
print("Drawing a circle")
class Square:
def draw(self) -> None:
print("Drawing a square")
def render(shape: Drawable) -> None:
shape.draw()
render(Circle()) # works — Circle has draw()
render(Square()) # works — Square has draw()
Neither Circle nor Square inherits from Drawable. They just happen to have the right methods. This is called structural typing (or “duck typing with type hints”) and is one of the most powerful features in Python’s type system.
Running mypy
mypy is the standard static type checker for Python. Install it and run it against your files:
pip install mypy
mypy script.py
Example: Catching a Bug
# buggy.py
def double(x: int) -> int:
return x * 2
result = double("hello") # wrong type — should be int
print(result)
Running mypy buggy.py:
buggy.py:4: error: Argument 1 to "double" has incompatible type "str"; expected "int"
Found 1 error in 1 file (checked 1 source file)
mypy caught the error before you ran the code.
Useful mypy Options
# Check all files in a directory
mypy src/
# Show error codes (useful for ignoring specific errors)
mypy --show-error-codes script.py
# Strict mode — enables many additional checks
mypy --strict script.py
# Ignore missing stubs for third-party libraries
mypy --ignore-missing-imports script.py
Ignoring Specific Lines
result = some_dynamic_function() # type: ignore[return-value]
Use # type: ignore sparingly — it is an escape hatch for cases where mypy is wrong or where the type is genuinely unknowable.
When to Use Type Hints
Good Candidates
Public APIs and library code — anyone calling your function benefits from knowing the expected types without reading the implementation.
Complex functions — when a function has many parameters or non-obvious return types, annotations serve as documentation.
Large codebases — type hints make refactoring safer. If you change a function signature, mypy finds all the call sites that need updating.
Dataclasses and named tuples — type hints are essentially required here and drive code generation.
When to Skip
Small scripts — a 30-line utility script does not need type annotations.
Highly dynamic code — functions that genuinely accept many types or use reflection extensively are hard to annotate cleanly, and the result is often more noise than signal.
Rapid prototyping — add types once the design stabilizes.
The Python community’s general recommendation: annotate public interfaces and leave internal implementation functions unannotated until you have a reason to add them.
Quick Reference
| Annotation | Meaning |
|---|---|
x: int | x is an integer |
x: str | None | x is a string or None |
x: list[str] | x is a list of strings |
x: dict[str, int] | x is a dict with string keys and int values |
x: tuple[int, str] | x is a tuple of (int, str) |
x: set[float] | x is a set of floats |
Callable[[int], str] | a function taking int, returning str |
TypeVar("T") | generic type variable |
Literal["a", "b"] | only these specific values |
Protocol | structural interface |
-> None | function returns nothing |
-> NoReturn | function never returns (raises or loops forever) |
Wrap-Up
Type hints make Python code easier to read, easier to refactor, and easier to debug. They are not enforced at runtime — they are a communication tool for humans and static analysis tools.
Start with the basics: annotate function parameters and return types in any code you share with others. Add mypy to your workflow to catch type errors before they become runtime crashes.
For related reading, Python decorators and type hints work naturally together — many decorator patterns become clearer when the type signatures are explicit. For questions or future tutorial ideas, get in touch via the Contact page.