Introduction
Python is a dynamically typed language, but since Python 3.5, type hints allow you to add static type information to your code. Type hints do not affect runtime behavior, but they provide several benefits:
- Improved readability: Function argument and return types are immediately clear
- Better IDE support: More accurate auto-completion and refactoring
- Early bug detection: Static type checkers like mypy catch errors before execution
- Self-documenting code: Types serve as inline documentation
This article covers type hints from basics to static type checking with mypy.
Type Hints Are Not Enforced at Runtime
Let’s start with the single most important property of Python type hints: unlike Java or TypeScript, they are not checked at runtime. The CPython interpreter merely stores type information in a function’s __annotations__ attribute; it never cross-checks that information against the actual arguments at call time. A type hint is “executable documentation” — whether it is ever validated is entirely up to an external static analyzer (mypy, pyright) or a runtime validation library (pydantic and similar).
Let’s see this concretely.
# double.py
def double(x: int) -> int:
return x * 2
# The hint says "x: int", but Python does not check that str violates it
result = double("ab")
print(repr(result))
print(type(result))
$ python3 double.py
'abab'
<class 'str'>
double("ab") runs without raising any exception and silently returns "abab" — a return value that violates the type hint (because str * 2 is interpreted as string repetition). The signature declares x: int -> int, but nothing checks that at runtime, so the bug slips through quietly.
Running mypy on the same file catches the mismatch before execution:
$ mypy double.py
double.py:6: error: Argument 1 to "double" has incompatible type "str"; expected "int" [arg-type]
Found 1 error in 1 file (checked 1 source file)
(Verified with mypy 2.3.0 / Python 3.14.6 — all output above is from an actual run.)
That difference is the essence of type hints: they are input data for static analysis tools, and the Python runtime reads but never validates them. The diagram below summarizes the two paths.

If you want type hints to act as a runtime guarantee, you need one of the following:
- Wire a static checker (mypy, pyright) into CI so violations are caught before merge
- Validate data boundaries (API input/output, etc.) with a runtime validation library such as pydantic
- Read type information with
typing.get_type_hints()and manually check it withisinstance()(expensive to maintain — usually a last resort)
Basic Type Hints
Variable Type Hints
Python 3.6+ supports type hints on variables.
name: str = "hello"
age: int = 30
height: float = 175.5
is_active: bool = True
Assigning a value that doesn’t match the type hint won’t cause a runtime error, but mypy will flag it.
name: str = 123 # mypy: Incompatible types in assignment
Function Type Hints
The most common use is annotating function arguments and return values.
def greet(name: str) -> str:
return f"Hello, {name}"
def add(a: int, b: int) -> int:
return a + b
Functions That Return Nothing
Use -> None for functions without a return value.
def log_message(message: str) -> None:
print(f"[LOG] {message}")
Default Arguments
When using default arguments, place the type hint before the =.
def greet(name: str, greeting: str = "Hello") -> str:
return f"{greeting}, {name}"
Collection Types
Python 3.9+ Built-in Generics
From Python 3.9 onward, built-in types can be used directly as generics.
# Python 3.9+
names: list[str] = ["Alice", "Bob"]
scores: dict[str, int] = {"Alice": 95, "Bob": 87}
coordinates: tuple[float, float] = (35.68, 139.76)
unique_tags: set[str] = {"python", "typing"}
Use ... for variable-length tuples.
# A tuple of arbitrary length containing ints
numbers: tuple[int, ...] = (1, 2, 3, 4, 5)
Python 3.8 and Earlier (typing Module)
Before Python 3.9, you need to import from the typing module.
from typing import List, Dict, Tuple, Set
names: List[str] = ["Alice", "Bob"]
scores: Dict[str, int] = {"Alice": 95}
coordinates: Tuple[float, float] = (35.68, 139.76)
unique_tags: Set[str] = {"python", "typing"}
Recommendation: On Python 3.9+, prefer built-in types (
list,dict,tuple,set). Thetyping.Listforms are being deprecated.
Nested Collections
Collection types can be nested.
# Dict with string keys and list of strings as values
user_tags: dict[str, list[str]] = {
"alice": ["python", "ml"],
"bob": ["go", "docker"],
}
# List of tuples
points: list[tuple[int, int]] = [(0, 0), (1, 2), (3, 4)]
Optional and Union
Optional
Use when a value can be None.
from typing import Optional
# Python 3.9 and earlier
def find_user(user_id: int) -> Optional[str]:
if user_id == 1:
return "Alice"
return None
Python 3.10+ supports the | operator for a more concise syntax.
# Python 3.10+
def find_user(user_id: int) -> str | None:
if user_id == 1:
return "Alice"
return None
Union
Use when a value can be one of multiple types.
from typing import Union
# Python 3.9 and earlier
def process(value: Union[int, str]) -> str:
return str(value)
# Python 3.10+
def process(value: int | str) -> str:
return str(value)
Optional Is Syntactic Sugar for Union
Optional[X] is exactly equivalent to Union[X, None].
# All of these are equivalent
from typing import Optional, Union
x: Optional[str]
x: Union[str, None]
x: str | None # Python 3.10+
TypedDict and dataclass
TypedDict
Define the types of dictionary keys and values. Useful for API responses and configuration.
from typing import TypedDict
class UserProfile(TypedDict):
name: str
age: int
email: str
is_active: bool
def get_user() -> UserProfile:
return {
"name": "Alice",
"age": 30,
"email": "alice@example.com",
"is_active": True,
}
user = get_user()
print(user["name"]) # "Alice" — IDE provides auto-completion
For optional keys, use total=False or per-key NotRequired (3.11+).
from typing import TypedDict, NotRequired # Python 3.11+
class UserProfile(TypedDict):
name: str
age: int
nickname: NotRequired[str] # Optional key
dataclass
For structured data, dataclass is often a better fit. Unlike TypedDict, it supports attribute access (.name).
from dataclasses import dataclass
@dataclass
class User:
name: str
age: int
email: str
is_active: bool = True
user = User(name="Alice", age=30, email="alice@example.com")
print(user.name) # "Alice"
print(user) # User(name='Alice', age=30, email='alice@example.com', is_active=True)
Use frozen=True for immutability.
@dataclass(frozen=True)
class Point:
x: float
y: float
p = Point(1.0, 2.0)
p.x = 3.0 # FrozenInstanceError
TypedDict vs dataclass
| Aspect | TypedDict | dataclass |
|---|---|---|
| Data structure | dict | Class instance |
| Access | d["key"] | obj.attr |
| JSON compat | Directly usable as dict | Conversion needed |
| Immutability | Not supported | frozen=True |
| Use case | API responses, config | Domain models, value objects |
Generics
Generic Functions with TypeVar
Abstract over types to create reusable functions.
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
# Type inference works
name = first(["Alice", "Bob"]) # str
number = first([1, 2, 3]) # int
You can constrain a TypeVar.
from typing import TypeVar
Number = TypeVar("Number", int, float)
def double(x: Number) -> Number:
return x * 2
double(5) # OK: int
double(3.14) # OK: float
double("hi") # mypy error
Python 3.12+ Syntax
Python 3.12 introduced a new syntax that eliminates the need to explicitly define TypeVar.
# Python 3.12+
def first[T](items: list[T]) -> T:
return items[0]
Generic Classes
from typing import TypeVar, Generic
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
if not self._items:
raise IndexError("Stack is empty")
return self._items.pop()
def is_empty(self) -> bool:
return len(self._items) == 0
# Type is determined at usage
int_stack = Stack[int]()
int_stack.push(1)
int_stack.push(2)
value: int = int_stack.pop() # 2
str_stack = Stack[str]()
str_stack.push("hello")
In Python 3.12+, classes also support the new syntax.
# Python 3.12+
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
Variance: Why list[int] Is Not a list[float]
Using generic types safely requires understanding variance. Arithmetically, int can stand in for float, but list[int] cannot be passed where list[float] is expected. The reason: a mutable container like list is invariant.
# variance_demo.py
from typing import Sequence
def sum_as_float(values: list[float]) -> float:
return sum(values)
def sum_seq_as_float(values: Sequence[float]) -> float:
return sum(values)
ints: list[int] = [1, 2, 3]
# list[int] is not a list[float] (list is invariant)
sum_as_float(ints) # mypy error
# Sequence[int] is compatible with Sequence[float] (Sequence is covariant)
sum_seq_as_float(ints) # OK
$ mypy variance_demo.py
variance_demo.py:16: error: Argument 1 to "sum_as_float" has incompatible type "list[int]"; expected "list[float]" [arg-type]
variance_demo.py:16: note: "list" is invariant -- see https://mypy.readthedocs.io/en/stable/common_issues.html#variance
variance_demo.py:16: note: Consider using "Sequence" instead, which is covariant
Found 1 error in 1 file (checked 1 source file)
Just as mypy’s own note suggests, sum_seq_as_float (whose parameter type is Sequence[float]) accepts the same ints without error. Why does this difference exist?
- Invariant:
list[int]cannot substitute forlist[float].listsupports writing (e.g.append), so if this substitution were allowed, the callee could dovalues.append(1.5), injecting afloatinto what the caller still believes is alist[int]. Code that later assumes every element is anintwould then receive1.5and break at runtime. mypy forbids the assignment outright to prevent this. - Covariant:
Sequence[int]can substitute forSequence[float](in a read-only position).Sequencehas no write methods likeappend— it only supports reading — so writing can never corrupt the type, and the intuitive relationship (“anintmay be read as afloat”) holds safely. - Contravariant: The reverse relationship applies to positions like function arguments. A function that accepts a more general type can substitute for one that requires a more specific type (e.g.
Callable[[object], None]can be used whereCallable[[int], None]is required). This is expressed by declaring aTypeVarwithcontravariant=True.
| Variance | Meaning | Examples | Writable |
|---|---|---|---|
| Invariant | No substitution relationship between A[int] and A[float] | list[T], dict[K, V], set[T] | Yes |
| Covariant | If int is a subtype of float, A[int] is a subtype of A[float] | Sequence[T], Tuple[T, ...], FrozenSet[T] | No (read-only) |
| Contravariant | The reverse relationship | Callable argument positions | N/A (function argument) |
As a practical rule of thumb, if a function only reads from a container without mutating it, prefer Sequence (a read-only, covariant collection) over list in the type hint — it produces a more flexible, easier-to-call API. Reserve list or MutableSequence for cases that genuinely need to mutate the container.
Protocol (Structural Subtyping)
Type-Safe Duck Typing
Protocol (Python 3.8+) brings type safety to Python’s duck typing philosophy.
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str:
...
class Circle:
def draw(self) -> str:
return "Drawing a circle"
class Square:
def draw(self) -> str:
return "Drawing a square"
def render(shape: Drawable) -> None:
print(shape.draw())
# Circle and Square don't explicitly inherit Drawable,
# but they pass type checking because they have a draw() method
render(Circle()) # OK
render(Square()) # OK
Protocol vs ABC
Protocol uses structural subtyping and does not require explicit inheritance.
from abc import ABC, abstractmethod
from typing import Protocol
# ABC: Explicit inheritance required (nominal subtyping)
class DrawableABC(ABC):
@abstractmethod
def draw(self) -> str:
...
class CircleABC(DrawableABC): # Must inherit
def draw(self) -> str:
return "circle"
# Protocol: No inheritance needed (structural subtyping)
class DrawableProtocol(Protocol):
def draw(self) -> str:
...
class CircleProtocol: # No inheritance. Just having draw() is enough
def draw(self) -> str:
return "circle"
Practical Example: Logger Interface
from typing import Protocol
class Logger(Protocol):
def log(self, message: str) -> None:
...
class ConsoleLogger:
def log(self, message: str) -> None:
print(f"[CONSOLE] {message}")
class FileLogger:
def __init__(self, path: str) -> None:
self.path = path
def log(self, message: str) -> None:
with open(self.path, "a") as f:
f.write(f"{message}\n")
def process_data(data: list[int], logger: Logger) -> int:
logger.log(f"Processing {len(data)} items")
result = sum(data)
logger.log(f"Result: {result}")
return result
# Both satisfy the Logger Protocol
process_data([1, 2, 3], ConsoleLogger())
process_data([1, 2, 3], FileLogger("/tmp/app.log"))
Static Type Checking with mypy
Installation and Basic Usage
pip install mypy
# Check a single file
mypy script.py
# Check an entire directory
mypy src/
# Check a package
mypy -p mypackage
Errors mypy Detects
# example.py
def greet(name: str) -> str:
return f"Hello, {name}"
result: int = greet("Alice") # error: Incompatible types in assignment
greet(123) # error: Argument 1 has incompatible type "int"
$ mypy example.py
example.py:4: error: Incompatible types in assignment
(expression has type "str", variable has type "int")
example.py:5: error: Argument 1 to "greet" has incompatible type "int";
expected "str"
Found 2 errors in 1 file (checked 1 source file)
Configuration
Manage settings in pyproject.toml or mypy.ini.
# pyproject.toml
[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
strict_equality = true
# For third-party libraries without type stubs
[[tool.mypy.overrides]]
module = ["some_untyped_library.*"]
ignore_missing_imports = true
–strict Mode
--strict enables the most rigorous checks. Recommended for new projects.
mypy --strict src/
Key options enabled by --strict:
| Option | Description |
|---|---|
disallow_untyped_defs | Forbid functions without type hints |
disallow_any_generics | Require list[int] instead of bare list |
warn_return_any | Warn on functions returning Any |
no_implicit_reexport | Forbid implicit re-exports |
strict_equality | Warn on == between incompatible types |
What mypy --strict Actually Catches
The following code looks like it might just work — but running mypy --strict on it surfaces three representative categories of error.
# strict_demo.py
from typing import Optional
# 1. Missing return type annotation
def get_display_name(user_id: int):
if user_id == 1:
return "Alice"
return "Guest"
# 2. Incompatible assignment
count: int = "42"
# 3. Optional used without a None check
def to_upper(name: Optional[str]) -> str:
return name.upper()
$ mypy --strict strict_demo.py
strict_demo.py:6: error: Function is missing a return type annotation [no-untyped-def]
strict_demo.py:13: error: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment]
strict_demo.py:18: error: Item "None" of "str | None" has no attribute "upper" [union-attr]
Found 3 errors in 1 file (checked 1 source file)
(Verified with mypy 2.3.0 / Python 3.14.6 — this is real output from an actual run.)
What causes each error, and how to fix it:
| Line | Error code | Cause | Fix |
|---|---|---|---|
| 5 | no-untyped-def | --strict enables disallow_untyped_defs, so a return type is required even when inferable | Add -> str: |
| 12 | assignment | The int-typed variable count is assigned a str | Change to count: int = 42, or widen the declared type deliberately |
| 17 | union-attr | Optional[str] (str | None) could be None, but .upper() is called unconditionally | Add a guard such as if name is None: return "" |
Any “Disables” Type Checking — Unlike object
Any is a special type that accepts anything, and mypy permits every operation on an Any-typed value without question. Worse, this “unchecked” status propagates through assignments and function calls, so once Any enters a codebase, type checking is effectively disabled for everything downstream of it. This is a common, gradual way large codebases lose the benefits of typing.
object, by contrast, is the strictest possible “accepts anything” type — it is the parent of every type, but only exposes the common interface (__str__, __eq__, etc.). Both look like “an argument that accepts anything,” but mypy treats Any and object completely differently.
# any_vs_object.py
from typing import Any
def use_any(value: Any) -> int:
# value is Any, so calling a nonexistent method is never flagged by mypy
return value.nonexistent_method()
def use_object(value: object) -> int:
# value is object, so calling a nonexistent method IS flagged by mypy
return value.nonexistent_method()
$ mypy any_vs_object.py
any_vs_object.py:12: error: "object" has no attribute "nonexistent_method" [attr-defined]
Found 1 error in 1 file (checked 1 source file)
Line 7 in use_any clearly calls a method that doesn’t exist, yet mypy reports zero errors for it. The identical call pattern in use_object (line 12) is correctly caught. When you need a parameter that can accept “anything” but still want type safety inside the function, reach for object instead of Any, and narrow it with isinstance() as needed.
Common Errors and Fixes
# 1. Missing return statement
def get_name(user_id: int) -> str:
if user_id == 1:
return "Alice"
# error: Missing return statement
# Fix: return "" or raise ValueError()
# 2. Incompatible types in assignment
data: list[int] = []
data.append("hello") # error
# Fix: data: list[int | str] = [] or data.append(123)
# 3. Item "None" of "Optional[str]" has no attribute "upper"
def process(name: str | None) -> str:
return name.upper() # error: name could be None
# Fix: Add a None check
# if name is None:
# return ""
# return name.upper()
# 4. type: ignore for specific lines (last resort)
result = some_untyped_function() # type: ignore[no-untyped-call]
Type Hints Best Practices
| Practice | Description |
|---|---|
| Start with public APIs | Begin by annotating function signatures (arguments and return types) |
Avoid Any | Any disables type checking. Use specific types or object instead |
Be explicit about Optional | Always use Optional / X | None when None is a possible return value |
Constrain TypeVar | Prefer bound or constrained types over unconstrained TypeVar |
| Adopt incrementally | Apply --disallow-untyped-defs gradually to existing projects |
Comment type: ignore | Add reasons: # type: ignore[arg-type] # legacy API |
| Integrate mypy in CI | Run mypy in pre-commit hooks or CI pipelines |
Include py.typed marker | Add a py.typed file when publishing typed libraries |
Typing Additions in Recent Python Versions
The typing-related spec has kept evolving actively since Python 3.12. Every example below was actually verified to run on Python 3.14.6.
| Version | PEP | What it adds |
|---|---|---|
| 3.12 | PEP 695 | New generic syntax (def first[T](...)), type Alias = ... statement (lazily evaluated TypeAliasType) |
| 3.13 | PEP 696 | Default values for type parameters (e.g. class Box[T = str]: ...) |
| 3.13 | PEP 705 | Read-only TypedDict keys via ReadOnly[...] |
| 3.13 | PEP 742 | TypeIs — a new type guard that narrows more intuitively than TypeGuard |
| 3.14 | PEP 649 / PEP 749 | Deferred evaluation of annotations — forward references now work without from __future__ import annotations |
The type statement (PEP 695) replaces TypeAlias with new syntax whose right-hand side is evaluated lazily (which makes it robust to forward references).
# Python 3.12+ (PEP 695): type alias statement
type StringList = list[str]
type Coordinate = tuple[float, float]
def normalize(values: StringList) -> StringList:
return [v.strip() for v in values]
print(normalize([" a ", "b "])) # ['a', 'b']
TypeIs (PEP 742) gives an isinstance-based narrowing function a precise return type. Variance matters here too: because Sequence is covariant, narrowing Sequence[object] to Sequence[str] works cleanly, but narrowing list[object] to list[str] the same way triggers a mypy error due to list’s invariance.
# Python 3.13+ (PEP 742): narrowing with TypeIs
from typing import TypeIs, Sequence
def is_str_seq(val: Sequence[object]) -> TypeIs[Sequence[str]]:
return all(isinstance(x, str) for x in val)
items: Sequence[object] = ["a", "b"]
if is_str_seq(items):
print("narrowed to Sequence[str]:", items)
$ mypy pep_features_final.py
Success: no issues found in 1 source file
ReadOnly (PEP 705) expresses per-key immutability on a TypedDict. Attempting to reassign such a key is caught by mypy with a dedicated error code.
# Python 3.13+ (PEP 705): read-only TypedDict key
from typing import TypedDict, ReadOnly
class Config(TypedDict):
name: str
version: ReadOnly[str]
def bump(c: Config) -> None:
c["version"] = "2.0" # writing to a ReadOnly key
$ mypy readonly_check.py
readonly_check.py:8: error: ReadOnly TypedDict key "version" TypedDict is mutated [typeddict-readonly-mutated]
Found 1 error in 1 file (checked 1 source file)
(All three code examples and mypy outputs above were actually run with mypy 2.3.0 / Python 3.14.6.)
Python 3.14 defers annotation evaluation for functions and classes (PEP 649 / PEP 749), so forward references — such as a not-yet-defined class name — can now appear in type hints without needing from __future__ import annotations. This can affect libraries that read annotations at runtime (dataclasses, pydantic, and similar), so consult the annotationlib module documentation before migrating.
Related Articles
- Python Decorators: Mechanics and Practical Patterns - Practical patterns for adding type hints to decorators.
- Introduction to Python asyncio
- Type hints for async functions (
Coroutine,Awaitable). - Python Regular Expressions Practical Guide
- Using type hints with
remodule (re.Match,re.Pattern). - Python logging Module Practical Guide - Related to Protocol pattern for logger interface design.
References
- Python Documentation: typing
- mypy Documentation
- PEP 484 – Type Hints
- PEP 604 – Allow writing union types as X | Y
- PEP 695 – Type Parameter Syntax
- PEP 544 – Protocols: Structural subtyping
- PEP 696 – Type Defaults for Type Parameters
- PEP 705 – TypedDict: Read-only items
- PEP 742 – Narrowing types with TypeIs
- PEP 649 – Deferred Evaluation of Annotations Using Descriptors
Related Tools
- DevToolBox - Free Developer Tools - 85+ developer tools including JSON formatter, regex tester, and more
- CalcBox - Everyday Calculation Tools - 61+ calculation tools including statistics, frequency conversion, and more