How do type hints and mypy/pyright improve code quality, and what are their limitations?
Quick Answer
Type hints (`def f(x: int) -> str:`) document expected types and let static type checkers (`mypy`, `pyright`) catch type mismatches, missing arguments, and `None`-related bugs **before running the code**, without any runtime cost since hints are (mostly) ignored at runtime. The main limitation: type hints are **not enforced at runtime** by default (Python doesn't stop you from calling `f('wrong type')`), so they only catch what the type checker sees — dynamically constructed calls, `Any`-typed values, and unchecked third-party code can silently bypass them.
Detailed Answer
What type hints look like, and what they don't do at runtime
def greet(name: str) -> str:
return f"hello, {name}"
greet(42) # runs FINE at runtime -- Python doesn't check the hint!
# f"hello, {42}" -> 'hello, 42' -- no error, just probably not intended
Type hints are not enforced by the Python interpreter itself — they're
metadata, stored on the function (greet.__annotations__), that tools
can optionally read and check. Calling greet(42) doesn't raise
TypeError on its own; catching this mismatch requires running a static
type checker separately.
Catching errors before running the code
def get_user(user_id: int) -> dict | None:
...
user = get_user("123") # mypy: error: Argument 1 has incompatible type "str"; expected "int"
user = get_user(123)
print(user["name"]) # mypy: error: Item "None" of "dict | None" has no attribute "__getitem__"
# (get_user's return type says it might be None!)
mypy/pyright statically analyze the code (no execution needed) and
flag both a wrong-type argument and a missed-None-check — the second
example is a genuinely common real-world bug class (forgetting a function
can return None) that static typing surfaces at review/CI time instead
of as a production AttributeError.
Real benefits beyond bug-catching
- IDE autocomplete/navigation improves dramatically — the editor knows a variable's type and can suggest its actual methods.
- Self-documenting signatures —
def process(items: list[Order]) -> Summary:communicates intent far better than an untyped signature plus a docstring that can drift out of sync. - Safer refactoring — renaming a field or changing a function's signature immediately surfaces every call site the type checker disagrees with.
The limitations
def process(data: Any) -> Any: # Any opts OUT of checking entirely
return data.whatever_method() # never flagged, regardless of what `data` actually is
import third_party_untyped_lib # if it ships no type stubs, calls into it are unchecked
result = third_party_untyped_lib.do_thing() # typed as Any by default
Anydisables checking for anything it touches — a common escape hatch that, if overused, silently reduces how much of the codebase is actually protected.- Untyped third-party code (no type stubs, no
py.typedmarker) is treated asAnyby default, creating blind spots at every boundary with such a library. - No runtime enforcement — a caller that ignores type errors (or
code paths the type checker can't see, like
getattr-based dynamic dispatch, or unchecked deserialized JSON) can still pass the wrong type through at runtime; for that, use runtime validation libraries (pydantic) at actual system boundaries. - Gradual, not all-or-nothing — a codebase can be partially typed, which is often the pragmatic starting point, but means coverage (and therefore protection) varies file by file until fully adopted.
Interview-ready summary: Type hints let mypy/pyright catch type
mismatches and missed-None bugs statically, before running the code,
with zero runtime cost and better IDE support as a side benefit — but
they're not enforced at runtime, so Any, untyped dependencies, and
unchecked dynamic code remain blind spots; use runtime validation
(pydantic) at actual data-entry boundaries where static checking alone
isn't sufficient.