Typing & Errors
Two systems, one goal: reliable code
Section titled “Two systems, one goal: reliable code”Python gives you two complementary tools for writing code that breaks loudly instead of silently:
- Type hints — annotations that describe what types a variable, parameter, or return value should hold. They are checked statically by tools like mypy or pyright, not at runtime.
- Exceptions — a signal mechanism that interrupts normal execution when something unexpected happens, letting you catch and recover or propagate errors up the call stack.
Together, type hints and exceptions let you communicate intent precisely and handle failures gracefully.
Type hints at a glance
Section titled “Type hints at a glance”def greet(name: str, count: int = 1) -> str: return (name + "! ") * countname: str, count: int, and -> str are type hints. Python ignores them at runtime — they only exist for static checkers and human readers. They do not make greet("Alice") slower or faster.
The exception model at a glance
Section titled “The exception model at a glance”try: result = int("abc")except ValueError as e: print(f"Caught: {e}")finally: print("Always runs")try wraps code that might fail. except catches a specific exception type. finally runs regardless of whether an exception occurred.
How they connect
Section titled “How they connect”Type hints and exceptions are not mutually exclusive — they serve different phases:
| Phase | Tool |
|---|---|
| Static analysis (before running) | Type hints + mypy/pyright |
| Runtime failures (while running) | Exceptions |
A function signature tells callers what types are expected; exceptions tell callers what can go wrong at runtime. Neither one substitutes for the other: a clean mypy --strict run does not mean the program cannot crash, and a program with careful try/except everywhere can still ship with Any scattered through every signature, providing no static guarantees at all.
Decision table: what to raise, what to catch, what to let through
Section titled “Decision table: what to raise, what to catch, what to let through”Every lesson in this module answers one piece of this table. Use it as a map while reading, or as a quick reference afterward.
| Situation | What to do | Why |
|---|---|---|
| Input from outside your program is invalid | raise ValueError/TypeError (or a custom subclass), never assert | assert is stripped entirely under python -O |
| You can meaningfully recover from a specific failure | except SpecificError:, act, continue | Broad except hides bugs that were never the failure you meant to handle |
| You cannot recover, but must clean up | try/finally, or a context manager | Cleanup must run whether or not the exception is ultimately handled |
| A library call fails and you add context | raise NewError(...) from original | Preserves the chain (__cause__) instead of losing the original error |
| You must log and continue at a top-level boundary | except Exception: (never bare except:), logging.exception(...) | Exception excludes KeyboardInterrupt/SystemExit; logging.exception keeps the traceback |
| A parameter should reject one clear shape | A precise type hint (Literal, Protocol, Sequence, …), not Any | Any gives up static checking entirely — see Type Hints |
| Several unrelated operations can fail independently | except* over an ExceptionGroup | Plain except cannot see inside a group |
| You deliberately want to ignore one specific failure | contextlib.suppress(SpecificError) | States exactly what is ignored, unlike except: pass |
Module roadmap
Section titled “Module roadmap”| Lesson | What you will learn |
|---|---|
| Type Hints | Annotating variables, params, returns; Optional, Union, invariance, Any, Protocol, TypedDict, TypeGuard/TypeIs, and where mypy and pyright disagree |
| Exceptions | The exception hierarchy, try/except/else/finally, raise vs raise e, EAFP vs LBYL, except*, reading a traceback |
| Context Managers | with, __enter__/__exit__, when __exit__ swallows an exception, ExitStack, contextlib.suppress |
| Custom Errors | Subclassing Exception, why except X as e: deletes e, implicit vs explicit chaining, add_note() |
Runnable overview demo
Section titled “Runnable overview demo”from typing import Optional
def find_item(items: list[str], target: str) -> Optional[int]: """Return the index of target in items, or None if not found.""" for i, item in enumerate(items): if item == target: return i return None
fruits = ["apple", "banana", "cherry"]
# type hints describe intent — no runtime enforcementindex = find_item(fruits, "banana")print(f"Found at index: {index}")
missing = find_item(fruits, "mango")print(f"Not found: {missing}")
# exceptions signal runtime failurestry: value = fruits[10]except IndexError as e: print(f"IndexError: {e}")
# the else clause runs only when no exception was raisedtry: value = fruits[1]except IndexError: print("Out of range")else: print(f"Got: {value}")Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”Keep two failure modes apart, since the rest of this module treats them very differently. A type error
is something a checker can catch before the program ever runs, by reading annotations — it costs nothing
at runtime and catches nothing a checker was not run against. A runtime exception is something Python
itself detects while the program runs, unwinding the call stack until a handler catches it or the program
exits. Decorative type hints with no checker run in CI, and defensive try/except blocks with no thought
given to which exception is expected, are the two most common ways both systems end up doing nothing
useful at all.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Type hints + mypy/pyright in CI | Whole classes of bugs caught before code runs | Someone has to actually run the checker and fix what it finds |
Precise except clauses | Bugs outside the expected failure still crash loudly | More clauses to write than one broad except |
| Custom exception hierarchy | Callers catch at the specificity they need | More classes to define and maintain |
| Context managers for cleanup | Cleanup guaranteed regardless of how the block exits | Slightly more ceremony than manual open/close |
Common Misconceptions
Section titled “Common Misconceptions”- “Type hints and exceptions solve the same problem.” Type hints prevent a class of bugs from ever running; exceptions handle failures that only exist because the program is actually running (a missing file, bad user input, a network timeout) — no amount of static typing eliminates those.
- “If the code has type hints, mypy must already be passing.” Type hints are just annotations; nothing
forces anyone to actually run a checker against them, and
Anyor# type: ignorecan make a “fully typed” file enforce nothing at all. - “Catching
Exceptionis basically the same as catching everything.” It deliberately excludesKeyboardInterrupt,SystemExit, andGeneratorExit— see the Exceptions lesson for why that boundary exists. - “A function with no explicit
raisecannot fail.” Nearly every operation (indexing, attribute access, arithmetic, a library call) can raise on its own; the absence of araisestatement says nothing about the absence of failure modes.
Reading AI-generated code
Section titled “Reading AI-generated code”This whole module trains one skill: reading a function’s signature and body and asking “what does this actually guarantee, versus what does it merely suggest?” Before trusting generated code in this area:
- Does a parameter or return type use
Any, effectively opting the whole function out of static checking? - Is there a bare
except:or anexcept Exception: passwith no logging, silently hiding failures? - Does the code use
assertfor anything that must be validated in production, where-Owould remove it? - Is a custom exception’s
except ... as e:binding read anywhere after its block has ended? - Was the code actually run through
mypy --strictandpyright, or do the type hints just look plausible?
🐛 Spot the bug
from typing import Any
def get_config(data: dict[str, object], key: str) -> Any: try: return data[key] except: return None
value = get_config({"timeout": "30"}, "timeout")print(value + 5)Show the answer
Two problems compound here. First, -> Any means the checker never verifies what get_config actually returns — callers get zero protection. Second, the bare except: means a missing key silently becomes None, indistinguishable from a real error. The config value "timeout": "30" is a str, not an int — get_config returns it unchanged, and value + 5 raises TypeError: can only concatenate str (not "int") to str far away from where the real mistake (storing a string instead of a number, or never validating the config) actually happened. The fix touches both lessons: type get_config precisely instead of Any, and catch KeyError specifically instead of everything.