Type Hints
Comprehensive guide on Type Hints.
Type Hints
Overview#
Python is dynamically typed — variables don't have a fixed type, and
nothing is checked at runtime by default. Type hints, introduced in
Python 3.5 (PEP 484), let you annotate variables, function parameters, and
return values with their intended types. Python itself never enforces
these hints at runtime; instead, they're used by editors for autocomplete,
by static analysis tools like mypy to catch bugs before running the
code, and by other developers reading the code to understand intent. This
notebook covers basic annotations, the typing module's key tools,
generics, Optional, Union, type aliases, Protocol, and how to
actually check your code with mypy.
1. Basic Type Hints#
Python 3.12 (WASM)def greet(name: str) -> str:
return f"Hello, {name}!"
age: int = 25
price: float = 19.99
is_active: bool = True
tags: list = ["python", "coding"]
The syntax variable: type annotates a variable, and -> type after a
function's parameter list annotates its return type. Note this is purely
informational at runtime:
Python 3.12 (WASM)def add(a: int, b: int) -> int:
return a + b
print(add("hello", "world")) # "helloworld" — Python does NOT enforce the hint!
Type hints don't change how the code runs; they're a form of documentation that tools can verify separately.
2. Hinting Collections#
Python 3.12 (WASM)from typing import List, Dict, Tuple, Set
names: List[str] = ["Alice", "Bob"]
scores: Dict[str, int] = {"Alice": 90, "Bob": 85}
coordinates: Tuple[float, float] = (12.5, 45.2)
unique_ids: Set[int] = {1, 2, 3}
Modern syntax (Python 3.9+)#
Since Python 3.9, you can use the built-in collection types directly as
generics, without importing from typing.
Python 3.12 (WASM)names: list[str] = ["Alice", "Bob"]
scores: dict[str, int] = {"Alice": 90, "Bob": 85}
coordinates: tuple[float, float] = (12.5, 45.2)
unique_ids: set[int] = {1, 2, 3}
This modern form is now preferred; the typing.List, typing.Dict, etc.
aliases still exist for backward compatibility with older Python versions.
3. Optional and Union#
Optional[X] means "an X, or None" — extremely common for function
parameters or return values that might not have a value.
Python 3.12 (WASM)from typing import Optional
def find_user(user_id: int) -> Optional[str]:
users = {1: "Alice", 2: "Bob"}
return users.get(user_id) # returns None if not found
result = find_user(3) # type checkers know this could be str OR None
Union[X, Y] means the value could be either type.
Python 3.12 (WASM)from typing import Union
def process(value: Union[int, str]) -> str:
return str(value)
process(42) # OK
process("hi") # OK
Modern syntax (Python 3.10+)#
Since Python 3.10, the | operator can express unions directly, without
importing anything.
Python 3.12 (WASM)def process(value: int | str) -> str:
return str(value)
def find_user(user_id: int) -> str | None: # equivalent to Optional[str]
...
4. Type Aliases#
For complex, repeated type annotations, a type alias improves readability.
Python 3.12 (WASM)from typing import Dict, List
# Old style: plain assignment
UserId = int
UserDatabase = Dict[UserId, str]
def get_user(db: UserDatabase, user_id: UserId) -> str:
return db[user_id]
# Modern style (Python 3.12+): explicit "type" statement
type UserDatabase = dict[int, str]
5. Functions as Values: Callable#
Python 3.12 (WASM)from typing import Callable
def apply_twice(func: Callable[[int], int], value: int) -> int:
return func(func(value))
def add_one(n: int) -> int:
return n + 1
print(apply_twice(add_one, 5)) # 7
Callable[[ArgType1, ArgType2], ReturnType] describes a function's
signature — here, Callable[[int], int] means "a function taking one
int and returning an int."
6. Generic Functions and Classes with TypeVar#
Sometimes a function's return type depends on its input type, in a way
that plain Union can't express precisely. TypeVar creates a "type
variable" that ties multiple hints together.
Python 3.12 (WASM)from typing import TypeVar, List
T = TypeVar("T")
def first_item(items: List[T]) -> T:
return items[0]
x = first_item([1, 2, 3]) # type checker infers: int
y = first_item(["a", "b", "c"]) # type checker infers: str
Generic classes#
Python 3.12 (WASM)from typing import Generic, TypeVar
T = TypeVar("T")
class Box(Generic[T]):
def __init__(self, content: T) -> None:
self.content = content
def get(self) -> T:
return self.content
int_box: Box[int] = Box(42)
str_box: Box[str] = Box("hello")
Modern generic syntax (Python 3.12+)#
Python 3.12 (WASM)class Box[T]:
def __init__(self, content: T) -> None:
self.content = content
def get(self) -> T:
return self.content
7. Any and object#
Any tells the type checker to skip checking entirely for that value —
useful as an escape hatch, but it should be used sparingly since it
disables type safety wherever it's applied.
Python 3.12 (WASM)from typing import Any
def process(data: Any) -> None:
print(data.whatever_attribute) # no error from the type checker, even if wrong
object is different — it means "any type is acceptable here, but you
can only do things that work on every object" (like str() or ==),
which is far more restrictive and safer than Any.
Python 3.12 (WASM)def describe(value: object) -> str:
return str(value) # OK, str() works on any object
def describe_bad(value: object) -> str:
return value.upper() # type checker ERROR: 'object' has no 'upper' method
8. Protocol: Structural Typing#
Protocol lets you define an interface based on behavior (which methods
an object has) rather than inheritance — matching Python's duck-typing
philosophy while still getting static type checking.
Python 3.12 (WASM)from typing import Protocol
class Speaker(Protocol):
def speak(self) -> str:
...
class Dog:
def speak(self) -> str:
return "Woof!"
class Robot:
def speak(self) -> str:
return "Beep boop"
def make_it_speak(entity: Speaker) -> None:
print(entity.speak())
make_it_speak(Dog()) # OK — Dog has a compatible speak() method
make_it_speak(Robot()) # OK — Robot too, with NO shared inheritance needed
Neither Dog nor Robot explicitly inherits from Speaker — a type
checker verifies compatibility purely based on the presence of a matching
speak() method, which mirrors how duck typing works at runtime.
9. TypedDict: Typed Dictionaries#
For dictionaries with a fixed, known set of string keys (common with JSON
data), TypedDict provides much more precise checking than a plain
Dict[str, Any].
Python 3.12 (WASM)from typing import TypedDict
class Movie(TypedDict):
title: str
year: int
rating: float
movie: Movie = {"title": "Inception", "year": 2010, "rating": 8.8}
# movie: Movie = {"title": "Inception"} # type checker ERROR: missing keys
# movie["year"] = "2010" # type checker ERROR: wrong type
10. Literal Types#
Literal restricts a value to a specific, fixed set of literal values —
useful for options/flags/modes.
Python 3.12 (WASM)from typing import Literal
def set_mode(mode: Literal["read", "write", "append"]) -> None:
print(f"Mode set to {mode}")
set_mode("read") # OK
set_mode("delete") # type checker ERROR: not one of the allowed literals
11. Checking Your Code with mypy#
Type hints do nothing on their own at runtime. To actually catch type
errors, run a static type checker like mypy against your code.
bashpip install mypy --break-system-packages mypy my_script.py
Python 3.12 (WASM)# script.py
def add(a: int, b: int) -> int:
return a + b
result = add("1", "2") # mypy would report: incompatible type "str"
bash$ mypy script.py
script.py:4: error: Argument 1 to "add" has incompatible type "str"; expected "int"
Running the script directly with python script.py would work without
any error at all — the mismatch is only caught by explicitly running
mypy.
Notes#
- Type hints are checked by external tools (
mypy,pyright), editors, and IDEs — Python's own interpreter ignores them completely at runtime. Optional[X]is exactly equivalent toUnion[X, None]— it exists purely for readability.- Prefer the modern syntax (
list[str],int | None) overtyping.List[str]/typing.Optional[int]for Python 3.9+/3.10+ codebases — it's shorter and needs no extra imports. Anyshould be a last resort, not a default — overusing it defeats the purpose of adding type hints in the first place.Protocolmatches Python's existing duck-typing philosophy far better than requiring formal inheritance from an abstract base class.- Adding type hints incrementally to an existing codebase is completely valid — you don't need to annotate everything at once for hints to add value.
Best Practices Checklist#
- Annotate function parameters and return types, especially for public/reusable functions
- Prefer modern syntax (
list[int],int | None) overtyping.List[int]/typing.Optional[int]on Python 3.9+/3.10+ - Use
Optional[X](orX | None) whenever a value might beNone - Avoid overusing
Any— it disables type checking wherever applied - Use
Protocolfor structural/duck-typed interfaces instead of forcing formal inheritance - Use
TypedDictfor dictionaries with a fixed, known set of keys (e.g. parsed JSON) - Use
Literalto restrict a parameter to a specific set of allowed values - Run
mypy(or a similar type checker) as part of your workflow — hints alone catch nothing without a checking tool - Add type hints incrementally to existing code rather than treating it as all-or-nothing