Basic Type Hints — Annotations That Help You Think
~22 min · type-hints, annotation, Optional, Union
Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete
Type hints don't change runtime behavior
Python type hints are annotations. They sit in the source code, get stored on the function in __annotations__, and are read by static type checkers (mypy, pyright) and IDEs. Python itself doesn't enforce them at runtime. def f(x: int) -> str: happily accepts a list at runtime — the contract exists at the level of tools, not the interpreter.
The basic syntax
Annotate parameters with : type, return values with -> type. Variables get : type too. The basic types you need: int, float, str, bool, None, bytes. For containers, modern Python (3.9+) uses lowercase: list[int], dict[str, int], tuple[int, str], set[str]. The capitalized List, Dict from typing are old-style, still work, but lowercase is preferred now.
Optional, Union, and the | operator
Optional[X] is X | None — "X or None." Union[X, Y] is X | Y (3.10+). The pipe syntax is the modern idiom; the imported names are still available but read worse. def f(x: int | None = None) -> str | None: is the standard shape for "might be missing."
Any — the type checker's escape hatch
Any means "don't check this." The type checker accepts any operation on an Any value. Useful when interfacing with untyped libraries or genuinely dynamic data. Don't sprinkle it everywhere — that defeats the point of typing. Use object if you mean "any object" (which is checked) and reserve Any for opt-out.
Principle: Type hints are documentation that gets checked. They tell readers what a function expects and what it returns, in a form that catches mistakes at edit time. The cost is small; the bug-prevention value scales with codebase size. Modern Python style: type-hint everything, because typing is now the default expectation.
Code
Basic annotations·python
def greet(name: str, age: int) -> str:
return f"hi {name}, age {age}"
# Variable annotations
user_id: int = 42
users: list[str] = ["alice", "bob"]
config: dict[str, int] = {"port": 8000, "workers": 4}
# Annotations are stored on the function
print(greet.__annotations__)
# {'name': <class 'str'>, 'age': <class 'int'>, 'return': <class 'str'>}
# Runtime doesn't enforce — this works at runtime, mypy would catch it
greet(123, "oops")
Modern container types — lowercase·python
# Old style (still works)
from typing import List, Dict, Tuple, Set
def old_style(items: List[int]) -> Dict[str, int]:
return {str(i): i for i in items}
# Modern style (3.9+) — preferred
def modern(items: list[int]) -> dict[str, int]:
return {str(i): i for i in items}
# Tuple — fixed-size and variable-size
Point = tuple[float, float] # exactly 2 floats
MixedTuple = tuple[int, str, bool] # specific types per position
IntList = tuple[int, ...] # variable length, all ints
Optional and the pipe — None handling·python
# Old style
from typing import Optional, Union
def old(x: Optional[int]) -> Union[str, None]:
if x is None:
return None
return str(x)
# Modern (3.10+)
def modern(x: int | None) -> str | None:
if x is None:
return None
return str(x)
# Default value pattern — the most common use of None
def fetch(key: str, default: str | None = None) -> str | None:
return {"a": "alpha"}.get(key, default)
Any vs object — when to use which·python
from typing import Any
def accept_anything(x: Any) -> int:
# Type checker allows ANY operation on x
return x.completely_made_up_method() # mypy: OK
def accept_object(x: object) -> int:
# Type checker requires you to narrow before accessing attributes
if isinstance(x, str):
return len(x)
raise TypeError
# Use Any for: untyped libraries, genuinely dynamic data, gradual typing.
# Use object for: 'any object', when you'll narrow with isinstance later.
Aliases — naming complex types·python
# Type aliases let you name a complex type once
from typing import Callable
UserId = int
UserData = dict[str, str | int | None]
Callback = Callable[[str, int], bool]
def process_user(uid: UserId, data: UserData, on_done: Callback) -> None:
if on_done("start", uid):
print("processed", data)
# 3.12+ explicit type statement
# type UserId = int — recognized as a TypeAlias
# More on this in lesson 5.
Write a function top_n(items: list[dict[str, int]], n: int = 3, key: str = 'score') -> list[dict[str, int]] that returns the top-n items sorted by key descending. Add proper type hints throughout. Test by passing a list of three dicts (with key 'score'). Then deliberately call it with an int instead of a list — observe that it runs but mypy would flag it.
Progress
Progress is local-only — sign in to sync across devices.