Intermediate
12 min read
#Python#Type Hints#typing#mypy#Static Analysis

Type Hints — The Complete Notebook

Comprehensive guide on Type Hints — The Complete Notebook.

Type Hints

1. Overview#

Python remains dynamically typed at runtime — type hints don't change execution — but they let tools like mypy, IDEs, and pydantic (used heavily by FastAPI) catch mistakes before code runs and make function contracts explicit for other developers.

Type hints are purely optional and ignored by the Python interpreter at runtime (with a few framework exceptions like Pydantic/FastAPI, which do read them to validate data).


2. Basic Type Hints#

🐍 Python
def greet(name: str) -> str: return f"Hello, {name}" age: int = 25 price: float = 19.99 is_active: bool = True tags: list = ["python", "ai"]

2.1 Variable Annotations#

🐍 Python
count: int count = 0 user_id: str = "u-1234"

3. Generic Collection Types#

Since Python 3.9, built-in collections support subscripting directly — no need to import List/Dict from typing anymore.

🐍 Python
def get_names() -> list[str]: return ["Asha", "Ravi"] def get_scores() -> dict[str, int]: return {"Asha": 92, "Ravi": 85} def get_unique_tags() -> set[str]: return {"python", "ai"} def get_coordinates() -> tuple[float, float]: return (12.9716, 77.5946)
Old style (typing, pre-3.9)Modern style (3.9+)
List[str]list[str]
Dict[str, int]dict[str, int]
Tuple[int, int]tuple[int, int]
Set[str]set[str]

4. Optional and Union#

🐍 Python
from typing import Optional, Union def find_user(user_id: str) -> Optional[dict]: # Optional[dict] means "dict OR None" return database.get(user_id) # might return None if not found def parse_id(value: Union[str, int]) -> int: # Union[str, int] means "str OR int" return int(value)

4.1 Modern | Syntax (Python 3.10+)#

🐍 Python
def find_user(user_id: str) -> dict | None: return database.get(user_id) def parse_id(value: str | int) -> int: return int(value)

5. Any, None, and Function Signatures#

🐍 Python
from typing import Any def process(data: Any) -> None: # Any tells the type checker "skip checking this — could be anything" # -> None means the function doesn't return a meaningful value print(data)

Overusing Any defeats the purpose of type hints — it's an escape hatch, not a default. Reach for a precise type, or a Union, before falling back to Any.


6. TypedDict — Typed Dictionaries#

Useful for describing the shape of dict-like data (e.g., JSON payloads) without creating a full class.

🐍 Python
from typing import TypedDict class UserPayload(TypedDict): name: str age: int email: str def create_user(data: UserPayload) -> None: print(data["name"], data["age"]) create_user({"name": "Kamal", "age": 30, "email": "kamal@example.com"})

6.1 Optional Keys with TypedDict#

🐍 Python
from typing import TypedDict, NotRequired class UserPayload(TypedDict): name: str age: int email: NotRequired[str] # this key can be omitted (Python 3.11+)

7. dataclass vs TypedDict vs NamedTuple#

ToolBacked ByMutableBest For
@dataclassRegular classYes (by default)Objects with behavior/methods
TypedDictdictYesTyping raw JSON/dict-shaped data
NamedTupletupleNoLightweight, immutable records
🐍 Python
from typing import NamedTuple class Point(NamedTuple): x: float y: float p = Point(3.0, 4.0) print(p.x, p.y) # 3.0 4.0

8. Callable — Typing Functions as Arguments#

🐍 Python
from typing import Callable def apply_operation(a: int, b: int, operation: Callable[[int, int], int]) -> int: # Callable[[int, int], int] = "a function taking two ints, returning an int" return operation(a, b) def add(a: int, b: int) -> int: return a + b result = apply_operation(3, 4, add)

9. Generics with TypeVar#

Lets you write functions/classes that work with multiple types while still preserving type relationships.

🐍 Python
from typing import TypeVar T = TypeVar("T") def first_item(items: list[T]) -> T: return items[0] print(first_item([1, 2, 3])) # inferred as int print(first_item(["a", "b", "c"])) # inferred as str

9.1 Generic Classes#

🐍 Python
from typing import Generic, TypeVar 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: return self._items.pop() int_stack: Stack[int] = Stack() int_stack.push(1) int_stack.push(2)

10. Protocol — Structural Typing ("Duck Typing" Made Explicit)#

A Protocol defines a required shape (methods/attributes) instead of a required inheritance chain — any object matching the shape satisfies the type, even without extending the Protocol class.

🐍 Python
from typing import Protocol class SupportsSpeak(Protocol): def speak(self) -> str: ... class Dog: def speak(self) -> str: return "Woof!" class Robot: def speak(self) -> str: return "Beep boop!" def announce(entity: SupportsSpeak) -> None: print(entity.speak()) announce(Dog()) # valid — has a matching speak() method announce(Robot()) # also valid — neither class inherits from SupportsSpeak

11. Type Checking with mypy#

Type hints are only enforced if you run a checker — Python itself ignores them at runtime.

bash
pip install mypy mypy my_script.py
🐍 Python
def add(a: int, b: int) -> int: return a + b add("2", "3") # mypy flags this as an error; Python itself would still run it fine

12. Common Pitfalls#

INCORRECT: Treating Type Hints as Runtime Validation#

🐍 Python
def process(age: int): return age * 2 process("25") # No error at runtime! Type hints alone don't enforce anything.

CORRECT: Fix — Validate Explicitly, or Use Pydantic#

🐍 Python
from pydantic import BaseModel class UserInput(BaseModel): age: int # Pydantic DOES enforce this at runtime, raising a validation error UserInput(age="25") # Pydantic coerces "25" -> 25 automatically UserInput(age="abc") # raises a ValidationError

INCORRECT: Overusing Any Everywhere#

Defeats the entire purpose — a codebase full of Any gets none of the benefits of static analysis.


13. Summary & Best Practices Checklist#

  • Use built-in generics (list[str], dict[str, int]) over typing.List/Dict on Python 3.9+.
  • Use X | None (3.10+) or Optional[X] for values that might be missing.
  • Reach for TypedDict when typing raw dict/JSON shapes, and @dataclass when you need behavior too.
  • Use Protocol for flexible, duck-typed interfaces instead of forcing inheritance.
  • Run mypy in CI if your team relies on type hints for safety — hints alone don't stop bad data at runtime.
  • Use Pydantic models (not bare type hints) wherever you need runtime validation, such as API request bodies.
Knowledge Checkpoint

Python Type Hints & Static Typing Checkpoint

Q1.Does standard CPython enforce type annotations at runtime by default?
AYes, it raises a TypeError if an incorrect argument type is passed.
BNo, type hints are ignored at runtime by CPython and are primarily used by static type checkers (like mypy) and IDEs.
CYes, but only when running with Python 3.12+.
DYes, if the function is decorated with `@typing.enforce`.
Q2.In Python 3.10+, what is the modern shorthand syntax for `Union[str, None]` (or `Optional[str]`)?
A`str | None`
B`str || None`
C`str & None`
D`str ? None`
Q3.What is `TypeVar` used for in the `typing` module?
ATo cast variable types dynamically.
BTo define generic type parameters that preserve relationships across function arguments and return types.
CTo validate JSON payloads.
DTo measure variable memory consumption.
Track Your Learning

Finished studying this notebook?

Mark this guide as completed to update your course progress roadmap.