Type hints are optional annotations, introduced by PEP 484, that declare the expected types of variables, function parameters, and return values. They do not affect runtime behaviour; they are used by static type checkers, IDEs, and documentation tools to catch type errors before the program runs.
Annotating your code for editors and humans
What you'll learn: How to add type annotations to function parameters, return types, and variables. How annotations help your editor catch mistakes before you run the code. What Optional means.
How to read this tab: Read the code examples and notice how the annotations look in practice. Then add type hints to one function you've already written — your editor will immediately start showing you type errors.
Type hints let you write down what type a value is supposed to be — a string, an integer, a list of numbers. Python ignores them at runtime, but editors and tools like mypy read them to catch mistakes (like passing a string where a number is expected) before you run the code.
Annotations are optional — but recommended. Python runs just fine without them. But with them, your editor shows errors before you run the code, autocomplete improves dramatically, and anyone reading your code knows immediately what types are expected. Start annotating new functions as you write them — you don't need to annotate everything at once.
Basic annotations
You annotate a function's parameters and return type with colons and an arrow. You can also annotate variables. PEP 484 defines the function syntax; PEP 526 defines variable annotations. Crucially, these are hints only — Python does not enforce them.
# Function annotations: param: type, and -> return_type
def greet(name: str) -> str:
return f"Hello, {name}"
def add(a: int, b: int) -> int:
return a + b
# A function that returns nothing uses -> None
def log(message: str) -> None:
print(message)
# Variable annotations (PEP 526)
age: int = 30
name: str = "Priya"
price: float = 99.99
is_active: bool = True
# Annotations do NOT enforce types at runtime:
def double(n: int) -> int:
return n * 2
print(double("ab")) # 'abab' — Python runs it anyway!
# A type checker (mypy) would flag this as an error before running.Python 3.9+ syntax change. Before 3.9, you had to write from typing import List, Dict, Tuple and use List[int]. From 3.9+, you can write list[int] directly — no import needed. The lowercase forms are preferred in new code. The capitalised versions (List, Dict) still work for backward compatibility but are considered legacy.
Built-in container types
Since Python 3.9 (PEP 585), you can use the built-in collection types directly as generic hints — list[int], dict[str, int], tuple[int, ...] — without importing from typing. Before 3.9 you used capitalised versions (List, Dict) from the typing module.
# Python 3.9+ — use built-in types directly (PEP 585)
def total(prices: list[float]) -> float:
return sum(prices)
def lookup(table: dict[str, int], key: str) -> int:
return table[key]
# A tuple of fixed types
point: tuple[int, int] = (3, 5)
# A tuple of variable length, all the same type
scores: tuple[int, ...] = (90, 85, 78, 92)
# Nested containers
matrix: list[list[int]] = [[1, 2], [3, 4]]
records: dict[str, list[str]] = {"fruits": ["apple", "mango"]}
# Pre-3.9 equivalent (still valid, needs imports)
from typing import List, Dict
def total_old(prices: List[float]) -> float:
return sum(prices)✅ Beginner tab complete — check your understanding
- I can annotate a function: def greet(name: str, age: int) -> str
- I can annotate a variable: count: int = 0
- I know that type hints are not enforced at runtime — they're documentation + static analysis
- I know that Optional[str] means "str or None"
Union, generics, and the | operator
What you'll learn: Union types (when a value can be one of several types). The Python 3.10+ | operator as cleaner union syntax. Generic container types (list[int], dict[str, float]). TypeVar for functions that preserve type relationships.
How to read this tab: After reading, install mypy (pip install mypy) and run it on a file with type hints. Seeing actual type errors caught before running code is the moment type hints become genuinely useful.
Optional does NOT mean the argument is optional. Optional[str] means "the value can be str or None". It says nothing about whether the argument has a default. A function def f(x: Optional[str]) still requires x to be passed — it just can be None. For a truly optional argument, give it a default: def f(x: Optional[str] = None).
Optional, Union, and the | operator
When a value can be one of several types, use a union. PEP 604 (Python 3.10+) added the X | Y syntax as a cleaner alternative to typing.Union[X, Y]. Optional[X] means "X or None" and is equivalent to X | None.
# Python 3.10+ — the | union operator (PEP 604)
def parse(text: str) -> int | float:
if "." in text:
return float(text)
return int(text)
# Optional[X] means "X or None" — extremely common for defaults
def find_user(user_id: int) -> str | None:
users = {1: "Priya", 2: "Arjun"}
return users.get(user_id) # returns str or None
result = find_user(99)
if result is not None:
print(result.upper()) # safe — checked for None first
# Pre-3.10 equivalents from the typing module
from typing import Optional, Union
def parse_old(text: str) -> Union[int, float]: ...
def find_old(uid: int) -> Optional[str]: ... # = Union[str, None]
# Other common typing constructs
from typing import Any, Callable
handler: Callable[[int, str], bool] # takes (int, str), returns bool
anything: Any = "could be literally anything" # opt out of checkingTypeVar preserves type relationships. def first(items: list[T]) -> T says: "I take a list of T and return a T — the same T." Without TypeVar, you'd have to use Any, which defeats the purpose. TypeVar is how you write functions that are type-safe across many types without writing one version per type.
Generics and TypeVar
A generic function or class works with multiple types while preserving the relationship between them. A TypeVar is a type variable — a placeholder that a checker fills in. Python 3.12 (PEP 695) added cleaner built-in syntax for declaring type parameters.
from typing import TypeVar
T = TypeVar("T")
# This function returns the SAME type it receives
def first(items: list[T]) -> T:
return items[0]
n: int = first([1, 2, 3]) # checker knows this is int
s: str = first(["a", "b"]) # checker knows this is str
# A generic class
from typing import Generic
class Box(Generic[T]):
def __init__(self, content: T) -> None:
self.content = content
def get(self) -> T:
return self.content
int_box: Box[int] = Box(42)
print(int_box.get()) # checker knows this is int
# Python 3.12+ syntax (PEP 695) — no TypeVar import needed
def first_new[T](items: list[T]) -> T:
return items[0]
class Box2[T]:
def __init__(self, content: T) -> None:
self.content = contentOptional[X] means "X or None", not "optional argument". It is purely about the value possibly being None. Whether an argument has a default value is a separate matter. Optional[int] is exactly int | None.list[int] (3.9+) vs List[int] (older). They mean the same thing. The lowercase built-in generics (PEP 585) are preferred in modern code; the capitalised typing versions remain for backward compatibility.✅ Intermediate tab complete — check your understanding
- I know that int | None (3.10+) and Optional[int] mean the same thing
- I can write list[int] instead of List[int] for container type hints (Python 3.9+)
- I understand what TypeVar is for: preserving type relationships between input and output
- I have run mypy on at least one file and fixed the type errors it found
Protocols, structural typing, and runtime introspection
What you'll learn: Protocols (PEP 544) — structural subtyping, the static version of duck typing. How annotations are stored in __annotations__ and can be inspected at runtime. How dataclasses use annotations to generate code.
How to read this tab: Protocols are particularly useful when writing library code that should accept any object that behaves a certain way, without requiring inheritance.
Protocols are duck typing made explicit. Instead of requiring inheritance (class Dog(Animal)), a Protocol says "any class that has these methods is compatible". This matches how Python actually works — functions don't care about the type, only about the interface. Protocols let you declare that interface explicitly for the type checker.
Protocols and structural typing (PEP 544)
PEP 544 introduced Protocol, which brings structural subtyping (often called "duck typing" made static) to Python's type system. A class satisfies a Protocol if it has the required methods and attributes — it does not need to explicitly inherit from the Protocol. This matches how Python code actually works: it cares whether an object behaves the right way, not what it inherits from.
from typing import Protocol
# Define behaviour, not a base class
class Drawable(Protocol):
def draw(self) -> str: ...
# Circle does NOT inherit from Drawable — but it matches the shape
class Circle:
def draw(self) -> str:
return "drawing a circle"
class Square:
def draw(self) -> str:
return "drawing a square"
# A function accepting anything that has a draw() method
def render(shape: Drawable) -> None:
print(shape.draw())
render(Circle()) # accepted — Circle structurally matches Drawable
render(Square()) # accepted — same reason
# @runtime_checkable allows isinstance() checks against the protocol
from typing import runtime_checkable
@runtime_checkable
class Sized(Protocol):
def __len__(self) -> int: ...
print(isinstance([1, 2, 3], Sized)) # True — lists have __len__mypy is the reference type checker. Run mypy yourfile.py and it will report type errors without executing the code. It's the closest Python has to a compiler's type checking step. Most large Python projects run mypy in CI to catch type errors before deployment.
Static checking with mypy, and runtime introspection
Type hints are checked by separate tools, not the interpreter. mypy is the reference type checker (others include pyright and pyre). You run it over your code, and it reports type errors without executing the program. Although hints are not enforced at runtime, they are stored in the __annotations__ attribute and can be inspected — frameworks like dataclasses and pydantic use this.
# Install the reference checker
pip install mypy
# Check a file or a whole project
mypy myscript.py
mypy src/
# Example output when a type is wrong:
# error: Argument 1 to "double" has incompatible type "str";
# expected "int"# Annotations are stored and inspectable at runtime
def add(a: int, b: int) -> int:
return a + b
print(add.__annotations__)
# {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}
# typing.get_type_hints resolves string annotations properly
from typing import get_type_hints
print(get_type_hints(add))
# dataclasses use annotations to generate __init__ from fields
from dataclasses import dataclass
@dataclass
class User:
name: str # these annotations drive the generated __init__
age: int
active: bool = True
u = User("Priya", 30)
print(u) # User(name='Priya', age=30, active=True)from __future__ import annotationsIf an annotation references a name not yet defined (such as a class referring to itself), wrap it in quotes as a string forward reference, or add from __future__ import annotations at the top of the file. That future import (PEP 563) makes all annotations be stored as strings and evaluated lazily, which sidesteps definition-order problems and slightly improves import time.
✅ Expert tab complete
- I can write a Protocol class that defines an interface without inheritance
- I know that Protocol uses structural typing (duck typing made static)
- I can use get_type_hints() to introspect annotations at runtime