Python Type Hints - Annotate Variables, Functions & Classes (Tutorial #29)
Video: Python Type Hints - Annotate Variables, Functions & Classes (Tutorial #29) by Taught by Celeste AI - AI Coding Coach
Python Type Hints: list[int], Optional, Union, mypy
def f(x: int) -> str:— annotates parameters and return type. Hints don't affect runtime; tools likemypycheck them statically. Modern Python:list[int]directly (nofrom typing import List).
Type hints are optional metadata — Python ignores them at runtime. But they're invaluable for catching bugs before they ship, for IDE autocomplete, and for self-documenting APIs.
Basic annotations
name: str = "Alice"
age: int = 25
height: float = 5.7
active: bool = True
def greet(name: str) -> str:
return f"Hello, {name}!"
def add(a: int, b: int) -> int:
return a + b
def log_message(msg: str) -> None:
print(f"[LOG] {msg}")
Syntax:
- Variable:
name: type = value. - Parameter:
name: typein the function signature. - Return:
-> typeafter the arglist. - No return →
-> None.
These are hints — Python doesn't enforce them. add("hello", "world") runs and returns "helloworld", no error.
Why bother?
# Without hints, IDE doesn't know what you can call:
def process(item):
return item.upper() # works for str, fails for int
# With hints:
def process(item: str) -> str:
return item.upper() # IDE knows; mypy will catch errors
Benefits:
- Static checking —
mypy,pyright, IDE inspectors catch type errors before runtime. - Autocomplete — IDE knows what methods are available.
- Documentation — signature shows the contract.
- Refactoring confidence — changing types reveals all dependent code.
Container types
In modern Python (3.9+), use built-in containers directly:
def average(numbers: list[float]) -> float:
return sum(numbers) / len(numbers)
def create_profile(name: str, age: int) -> dict[str, int | str]:
return {"name": name, "age": age}
def get_pairs() -> list[tuple[str, int]]:
return [("Alice", 30), ("Bob", 25)]
For older Python (3.5-3.8), import from typing:
from typing import List, Dict, Tuple
def average(numbers: List[float]) -> float:
...
The typing import still works in modern Python; the lowercase list[float] form is preferred.
Optional and None
from typing import Optional
def find_user(users: dict[str, int], name: str) -> Optional[int]:
return users.get(name)
Optional[X] means "X or None." Equivalent to X | None.
In Python 3.10+:
def find_user(users: dict[str, int], name: str) -> int | None:
return users.get(name)
The | syntax is cleaner and matches the runtime isinstance(x, int | None) form.
Union: multiple possible types
from typing import Union
def parse(value: Union[str, int]) -> str:
return str(value)
Union[A, B] means "A or B." Modern syntax (3.10+):
def parse(value: str | int) -> str:
return str(value)
Type aliases
UserProfile = dict[str, str | int]
def format_profile(profile: UserProfile) -> str:
return f"{profile['name']}, age {profile['age']}"
When a complex type appears repeatedly, give it a name. Improves readability.
In modern Python, declare with TypeAlias to be explicit:
from typing import TypeAlias
UserProfile: TypeAlias = dict[str, str | int]
Callable
from typing import Callable
def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
return func(a, b)
result = apply(lambda x, y: x + y, 3, 5)
Callable[[arg_types], return_type]. Empty arg list Callable[[], int] means "no arguments, returns int."
Any: opt out
from typing import Any
def loose(x: Any) -> Any:
return x.whatever()
Any disables type checking for that value. Use sparingly — it defeats the purpose. Reserve for genuinely dynamic interfaces.
Generic types
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
x: int = first([1, 2, 3]) # T inferred as int
y: str = first(["a", "b"]) # T inferred as str
TypeVar("T") declares a generic. The function preserves the type — pass a list[int], get back an int.
Class type hints
class Point:
x: float
y: float
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def distance_to(self, other: "Point") -> float:
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
Class attributes can be annotated at class level. Methods annotate self implicitly (no need to type it).
"Point" (string) is a forward reference — the class isn't fully defined when the method body is parsed. In Python 3.10+ with from __future__ import annotations, this is no longer needed.
mypy: the static type checker
pip install mypy
mypy your_module.py
def add(a: int, b: int) -> int:
return a + b
add("hello", "world") # mypy: error: Argument 1 has incompatible type "str"; expected "int"
mypy reads your code (without running it) and reports type mismatches. Add it to CI to catch type bugs before they ship.
Other tools
- pyright — Microsoft's checker; faster than mypy; embedded in VS Code's Pylance.
- pyre — Facebook's checker.
- pytype — Google's checker.
All read the same hints; pick one.
Real-world patterns
from typing import Iterable, Iterator
def sum_squares(items: Iterable[int]) -> int:
return sum(x ** 2 for x in items)
def evens(items: list[int]) -> Iterator[int]:
return (x for x in items if x % 2 == 0)
Iterable[T] — anything you can iterate (list, tuple, generator, ...). Use as parameter type for max flexibility.
Iterator[T] — what generators return.
Literal types
from typing import Literal
def set_mode(mode: Literal["read", "write", "append"]) -> None:
...
set_mode("read") # OK
set_mode("delete") # mypy error
When the valid values are a small known set, use Literal. Better than a free-form string.
TypedDict
from typing import TypedDict
class User(TypedDict):
name: str
age: int
email: str
u: User = {"name": "Alice", "age": 30, "email": "a@x.com"}
For dicts with a fixed schema, TypedDict documents the keys and types. Better than dict[str, Any] when the dict has structure.
When to add hints
- Public APIs — yes. Document the contract.
- Library code — yes. Users get autocomplete.
- Complex types — yes. Hints often more readable than the code.
- Throwaway scripts — no. Don't bother.
The middle ground: hint at boundaries (function signatures, class attributes), let local variable types be inferred.
Common stumbles
Old syntax in new code. List[int] works but list[int] is preferred (Python 3.9+).
Hints that don't match reality. def f(x: int) -> int: return str(x) — mypy catches it; your runtime tests should too.
Missing from __future__ import annotations. Forward references and | types are lazy-evaluated when this is at the top of the file.
Treating hints as runtime. def f(x: int): ... lets f("hello") succeed. Hints are not validation — use isinstance if you need runtime checks.
Over-annotating local variables. total: int = 0 is rarely worth the noise. Annotate when the type isn't obvious from context.
Using Any to silence mypy. Defeats the point. Fix the underlying issue, or use # type: ignore with a comment explaining why.
What's next
Lesson 30: dataclasses. @dataclass for boilerplate-free classes — __init__, __repr__, __eq__ generated for you.
Recap
name: type and def f(x: type) -> type: annotate. Built-in containers: list[int], dict[str, int], tuple[str, int]. Optional[X] or X | None for nullable. Union[A, B] or A | B for either. TypeVar for generics. Callable[[arg_types], return] for functions. Hints don't enforce at runtime — install mypy (or pyright) to check statically. Annotate at boundaries; let local types be inferred.
Next lesson: dataclasses.