Python

Type hints and static typing

Python never stops being dynamically typed at runtime — type hints are an optional, separate layer that lets a tool catch a category of bugs before the code ever runs, without changing what Python actually does.

Intermediate

4 min read

The thing type hints don't do, stated first

def greet(name: str) -> str:
    return "Hello, " + name
 
greet(42)   # runs fine — Python does NOT enforce the hint at runtime

This is the single most important fact about type hints: name: str is not a runtime check. Calling greet(42) doesn't raise any error — Python's actual runtime behavior is completely unaffected by type hints, which remain exactly as dynamically typed as ever. Type hints are pure documentation as far as the Python interpreter is concerned; they only become useful once a separate tool actually reads and checks them.

What actually enforces them: a static type checker

def greet(name: str) -> str:
    return "Hello, " + name
 
greet(42)   # mypy: error: Argument 1 to "greet" has incompatible type "int"; expected "str"

mypy (the most common Python type checker) reads the hints and analyzes the code without running it — "static" analysis, as opposed to catching the error at runtime. Running mypy on the file above reports the type mismatch before the code is ever executed, the same category of "catch this before it ships" value that automated tests provide, but checking types specifically rather than behavior. This is the actual point of type hints: not changing what Python does, but giving a separate tool enough information to catch an entire class of mistakes ahead of time.

The basic syntax

def calculate_total(price: float, quantity: int, discount: float = 0.0) -> float:
    return price * quantity * (1 - discount)
 
name: str = "Ada"
count: int = 0
active: bool = True

Function parameters and return values are annotated with : type after the name and -> type before the colon, respectively; variables can be annotated the same way, though it's less commonly needed since the type is usually obvious from the assigned value. A default value (discount: float = 0.0) works exactly the same as without hints — the hint and the default are independent, unrelated syntax that happen to appear on the same line.

Hinting collections: what's inside matters

def get_names(users: list[dict[str, str]]) -> list[str]:
    return [user["name"] for user in users]

list[str] says "a list containing strings," not just "a list" — this extra precision is what lets a type checker catch passing list[int] where list[str] was expected, a mistake a bare list hint couldn't catch at all. dict[str, str] similarly specifies both the key and value types. (Older Python versions required List[str]/Dict[str, str] from the typing module — the lowercase built-in syntax shown here works directly since Python 3.9.)

Optional and |: a value that might be None

def find_user(user_id: int) -> str | None:
    ...   # returns a name, or None if no such user exists
 
user = find_user(5)
print(user.upper())   # mypy flags this: user might be None, which has no .upper()

str | None (or the older, equivalent Optional[str]) tells a type checker this function might return None instead of a real string — which is exactly the situation the exception-handling lesson's dict.get() example runs into. A type checker that sees str | None will flag user.upper() as potentially calling a method on None, catching the exact class of AttributeError: 'NoneType' object has no attribute bug before it ever runs, by forcing the code to handle the None case explicitly (an if user is not None: check) before using the value.

Why bother, if Python runs fine either way

# Without hints — what does this function actually expect and return?
def process(data, options=None):
    ...
 
# With hints — the signature itself documents the contract
def process(data: list[dict[str, Any]], options: ProcessOptions | None = None) -> ProcessResult:
    ...

Type hints make a function's contract readable directly from its signature, without opening the body or reading documentation — genuinely valuable the moment more than one person (or future-you, months later) has to call a function without already knowing exactly what it expects. They also make editors and IDEs meaningfully better at autocomplete and inline error detection, since the editor can now know what type a variable actually is instead of guessing. The trade-off is real too: hints add visual noise to simple code, and a large codebase migrating to full type coverage is genuine, ongoing work — which is why hints are opt-in and gradual by design, not an all-or-nothing requirement.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. Does def greet(name: str) -> str: prevent greet(42) from actually running?

2. What does it mean that mypy performs 'static' type checking?

3. Why does hinting a parameter as list[str] catch more mistakes than hinting it as just list?

4. What bug does a return type hinted as str | None help catch before the code runs?