Info
This guide covers practical type-hinting patterns for most everyday use cases in modern Python codebases. It is aimed at intermediate developers and assumes a basic understanding of Python type hints. For a more comprehensive reference, see python’s official documentation or the mypy documentation.
Built-in types
Basic Types
# Most type checkers can infer the type of a variable from its value
x: int = 1
x: float = 1.0
x: bool = True
x: str = "String"
x: bytes = b"Bytes"Collections
Concrete Types
# In Python 3.9+
x: list[int] = [1, 2]
x: set[int] = {5, 6}
# For tuples of fixed size
x: tuple[int, str, float] = (3, "yes", 7.5)
# For tuples of variable size, we use one type and ellipsis
x: tuple[int, ...] = (1, 2, 3, 4)
# For mappings, we need to types of both keys and values
x: dict[str, float] = {"key": 2.0}Python 3.8 or earlier
from typing import List, Set, Dict, Tuple
x: List[int] = [1, 2]
x: Set[int] = {5, 6}
# For tuples of fixed size
x: Tuple[int, str, float] = (3, "yes", 7.5)
# For tuples of variable size, we use one type and ellipsis
x: Tuple[int, ...] = (1, 2, 3, 4)
# For mappings, we need to types of both keys and values
x: Dict[str, float] = {"key": 2.0}Abstract Types
Apply "Postel's Law" in Type Hints
Be conservative in what you send, be liberal in what you accept.
Ideally functions should accept arguments using abstract types which gives more flexibility to the caller: “be liberal in what you accept”. On the other hand, the return value of a function should be as strict as possible (e.g., using concrete types if applicable): “be conservative in what you send”.
| Abstract Type | Concrete Types | What your code needs… |
|---|---|---|
Iterable[T] | list, tuple, set, str, dict | Loop over the items one by one. Indexing x[0] or len(x[0]) not required |
Sequence[T] | list, tuple, str | (Read-only) Loop, get the length, and access items by index. |
MutableSequence[T] | list | Same as Sequence AND can modify the list (add/remove/update items). |
Mapping[K, V] | dict | (Read-only) Look up values by key x[k] and loop over keys. |
MutableMapping[K, V] | dict | Same as Mapping AND can modify the dictionary (set x[k]=v, delete items). |
Set[T] | set, frozenset | (Read-only) Check membership (x in y) and perform set operations. |
MutableSet[T] | set | Same as Set AND can modify the set (add/discard items). |
Iterable Example
from collections.abc import Iterable
# Input: Liberal (Iterable). Accepts lists, tuples, sets, or generators.
# Output: Conservative (list). The caller knows exactly what they get back.
def extract_emails(users: Iterable[str]) -> list[str]:
# We only loop, so 'Iterable' is all we need.
valid_emails = []
for user in users:
if "@" in user:
valid_emails.append(user)
return valid_emailsSequence Example
from collections.abc import Sequence
# Input: Liberal (Sequence). Accepts list, tuple, string.
# Output: Conservative (int). A specific concrete type.
def get_middle_value(data: Sequence[int]) -> int:
# We need length and index access, so we use a Sequence
if not data:
return 0
middle_index = len(data) // 2
return data[middle_index]Union - Multiple Return Types
# In Python 3.10+ use the | operator when something could be one of a few types
x: int | str = 3
x: int | str = "three"
x: list[int | str] = [1, "one", 2, 3]Python 3.9 or earlier
from typing import List, Union
x: Union[int, str] = 3
x: Union[int, str] = "three"
x: list[Union[int, str]] = [1, "one", 2, 3]Optional types and the None type
x: str | None = "something" if some_condition() else NonePython 3.9 or earlier
from typing import Optional
x: Optional[str] = "something" if some_condition() else NoneInfo
Optional[X]is the preferred shorthand forUnion[X, None]
TypedDict
from typing import TypedDict, NotRequired
class Person(TypedDict):
first_name: str
last_name: str
# middle_name may be omitted from the dictionary entirely
middle_name: NotRequired[str]
# ✅ OK
john: Person = {"first_name": "John", "last_name": "Doe", "middle_name": "James"}
# ✅ OK: if middle_name is missing
alice: Person = {"first_name": "Jane", "last_name": "Doe"}
# ❌ Error: last_name is missing
james: Person = {"first_name": "James"}NamedTuple
Why use NamedTuple over regular tuple?
For improved code readability and self-documentation, and they are as memory efficient as regular tuples. It also allows you to access elements using descriptive field names and dot notation, in addition to standard positional indices.
from typing import NamedTuple
class Point(NamedTuple):
x: int
y: int
label: str = "Point" # Default values are allowed
point = Point(10, 20)Functions
from collections.abc import Iterator
def say_hello(name: str) -> str:
return f"Hello {name}!"
# Annotating function with multiple arguments
def add(a: int, b: int) -> int:
return a + b
# If a function does not return a value, use None as the return type
def print_debug(msg: str) -> None:
print(f"DEBUG: {msg}")Callable - Annotating Functions as Variables
Info
The
Callabletype is used when a variable, argument, or return value is a function (or any object that can be called, like a class or an object with__call__). It is most commonly used for callbacks (passing a function into another function).
It has the following syntax:
Callable[[Arg1, Arg2, ...], ReturnType]Example:
from collections.abc import Callable, Sequence
def filter_numbers(data: Sequence[int], condition: Callable[[int], bool]) -> list[int]:
result = []
for num in data:
if condition(num): # We call the passed function here
result.append(num)
return result
nums = [1, 2, 3, 4, 5]
evens = filter_numbers(nums, lambda x: x % 2 == 0)
odds = filter_numbers(nums, lambda x: x % 2 != 0)Python 3.9 or earlier
from typing import Callable
def filter_numbers(data: list[int], condition: Callable[[int], bool]) -> list[int]:
result = []
for num in data:
if condition(num): # We call the passed function here
result.append(num)
return result
evens = filter_numbers(nums, lambda x: x % 2 == 0)
odds = filter_numbers(nums, lambda x: x % 2 != 0)Overloading - Mapping Input Values to Specific Output Types
Use @overload when a function’s return type changes based on a specific input value based on a specific flag or mode.
from typing import overload
# 1. If mode is "r" (or default), return str
@overload
def load_file(path: str, mode: Literal["r"] = "r") -> str: ...
# 2. If mode is "rb", return bytes
@overload
def load_file(path: str, mode: Literal["rb"]) -> bytes: ...
# 3. Implementation (must cover all cases)
def load_file(path: str, mode: str = "r") -> str | bytes:
with open(path, mode) as f:
return f.read()Note
The implementation signature (
mode: str) must be broad enough to accept all overloaded variants, but type checkers will use the specific@overloadsignatures to validate usage.
Classes
from typing import (
ClassVar,
Self, # Available from Python 3.11+ or from `typing_extensions` package
)
class Employee:
# Class Variables is annotated with ClassVar
annual_bonus: ClassVar[float] = 0.1
def __init__(self, name: str, salary: int) -> None:
# type checkers can will infer the correct types for instance variables
self.name = name
self.salary = salary
# For instance methods, omit type for "self"
def calculate_annual_bonus(self) -> float:
return self.salary * self.annual_bonus
def set_salary(self, new_salary: int) -> Self:
if new_salary < 0:
raise ValueError("Salary cannot be negative")
self.salary = new_salary
return self
# User-defined classes are valid as types in annotations
employee: Employee = Employee("John", 80_000)Note
We use
typing.Selfto ensure that methods defined in a parent class return the specific type of the subclass calling them, rather than the parent type.
Overriding statically typed methods
When overriding a statically typed method, type checkers can check that the override has a compatible signature. In order to ensure your code remains correct, it can be helpful to explicitly mark a method as overriding a base method using the @override decorator.
from typing import override # Available from Python 3.12+
class Manager(Employee):
@override
def calculate_annual_bonus(self) -> float:
return self.salary * self.annual_bonus + 10_000Python 3.11 or earlier
from typing_extensions import override
class Manager(Employee):
@override
def calculate_annual_bonus(self) -> float:
return self.salary * self.annual_bonus + 10_000Preventing Subclassing or Method Override
The @final decorator is used to tell type checkers (like mypy) and other developers that a specific class or method should not be modified further.
No Method Override in Subclasses
from typing import final # Available from Python 3.8+
class PaymentProcessor:
@final
def verify_identity(self):
"""
This core security method must never be changed by subclasses.
"""
print("Verifying user identity via encrypted gateway...")
def calculate_fees(self):
"""
This can be overridden by any subclass of PaymentProcessor
"""
print("Calculating standard 2% fee.")No Further Subclassing
from typing import final # Available from Python 3.8+
@final
class SecureVault(PaymentProcessor):
"""
This class is marked @final, meaning no one can create
a subclass of this class.
"""
def store_data(self):
print("Data stored in an un-subclassable vault.")Special Types & Aliases
Type Alias
Type Alias Definition
A type alias is an alternative name for an existing type, used to make complex type hints more readable and to add semantic meaning to the code.
type Coordinate = tuple[float, float]
def move_to(coordinate: Coordinate) -> None: ...Python 3.11 or earlier
from typing import TypeAlias, Tuple, Union
Coordinates: TypeAlias = Tuple[float, float]
def move_to(coordinate: Coordinate) -> None: ...Literal Type
Literal Definition
A literal is a variable has a specific and concrete value (e.g.,
10,"hello",True)
from typing import Literal
def open_file(mode: Literal["r", "w", "rb", "wb"]) -> None: ...The “Any” Type
Use
Anysparingly. It effectively turns off the type checker for that part of the code. Consider usingobjectinstead.
from typing import Any
def parse_legacy_data(data: Any) -> None:
data.do_anything() # ✅ OK: type checker ignores this.The “object” Type
Everything is an object
In Python,
objectis the root base class for all types. Integers, strings, functions, and custom classes all inherit from it. This makesobjectthe most general type possible. It allows your code to accept absolutely any item (universal compatibility), but prevents you from assuming anything about what that item can do.
class Cache:
def __init__(self):
# Allow caching ANY generic object (Black Boxes).
self._store: dict[str, object] = {}
def cache_value(self, key: str, value: object) -> None:
self._store[key] = value
def get_value(self, key: str) -> object | None:
# When we return it, it is still a "Black Box" (object).
# The caller must figure out what is inside.
return self._store.get(key) def parse_legacy_data(data: object) -> None:
data.do_anything() # ❌ ERROR: 'object' has no 'lower' method.Generics
Info
Generics allow you to define functions and classes that can work with any data type, while still maintaining strict type safety.
Generic Functions
# A generic function that takes a list of T and returns a list of the same type T.
def reverse[T](items: list[T]) -> list[T]:
return items[::-1]
numbers = [1, 2, 3]
reversed_nums = reverse_list(numbers) # Type of reversed_nums is list[int].
words = ["apple", "banana"]
reversed_words = reverse_list(words) # Type of reversed_words is list[str]Python 3.11 or earlier
from typing import TypeVar, List
T = TypeVar("T") # Need to declare the generic type variable
def reverse(items: List[T]) -> List[T]:
return items[::-1]Generic Type Variable
T
Tstands for “Type” and is the most common naming convention used. Using the example above, it tells the type checker that we don’t care what type of data is in this list (e.g.,int,float,str), but whatever it is, I want to return a list of the exact same type.
Generic Classes
# This tells Python: "This entire class is built around the type T."
# If I initialize this as Stack[int], then everywhere you see 'T' inside,
# imagine it says 'int'.
class Stack[T]:
def __init__(self):
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
stack = Stack[int]() # T becomes 'int' for this instance
stack.push(10) # ✅ OK: 10 is an int
stack.push("ten") # ❌ ERROR: "ten" is not an int
x = stack.pop() # Type checker knows 'x' is an intPython 3.11 or earlier
from typing import Generic, TypeVar, List
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self):
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
stack = Stack[int]() # T becomes 'int' for this instance
stack.push(10) # ✅ OK: 10 is an int
stack.push("ten") # ❌ ERROR: "ten" is not an int
x = stack.pop() # Type checker knows 'x' is an intMultiple Generic Types
It is possible to define a function or class with multiple generic types by just listing them in the function or class definition.
Function Example:
# T represents the type of the First item
# U represents the type of the Second item
def make_pair[T, U](first: T, second: U) -> tuple[T, U]:
return (first, second)
coordinates = make_pair(10.0, 50.5) # Type: tuple[float, float]
user_age = make_pair("John", 20) # Type: tuple[str, int]Class Example:
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import override
# Define generic type parameters [T_Obj, T_Data] for this abstract class.
# These act as placeholders that will be defined by subclasses later.
class Serializer[T_Obj, T_Data](ABC):
@abstractmethod
def serialize(self, item: T_Obj) -> T_Data:
"""Convert the high-level object to low-level data."""
@abstractmethod
def deserialize(self, data: T_Data) -> T_Obj:
"""Convert low-level data back to the high-level object."""
class User:
def __init__(self, name: str):
self.name = name
@dataclass
class UserData:
name: str
# Concrete class binds the specific types to the placeholders:
# T_Obj becomes User, and T_Data becomes UserData.
class UserSerializer(Serializer[User, UserData]):
@override
def serialize(self, item: User) -> UserData:
return UserData(name=user.name)
@override
def deserialize(self, data: UserData) -> User:
return User(name=data.name)Python 3.11 or earlier
Function Example:
from typing import TypeVar, Tuple
T = TypeVar("T")
U = TypeVar("U")
# T represents the type of the First item
# U represents the type of the Second item
def make_pair(first: T, second: U) -> Tuple[T, U]:
return (first, second)
coordinates = make_pair(10.0, 50.5) # Type: tuple[float, float]
user_age = make_pair("John", 20) # Type: tuple[str, int]Class Example:
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Generic, TypeVar
from typing_extensions import override
T_Obj = TypeVar("T_Obj")
T_Data = TypeVar("T_Data")
# Define generic type parameters [T_Obj, T_Data] for this abstract class.
# These act as placeholders that will be defined by subclasses later.
class Serializer(Generic[T_Obj, T_Data], ABC):
@abstractmethod
def serialize(self, item: T_Obj) -> T_Data:
"""Convert the high-level object to low-level data."""
@abstractmethod
def deserialize(self, data: T_Data) -> T_Obj:
"""Convert low-level data back to the high-level object."""
class User:
def __init__(self, name: str):
self.name = name
@dataclass
class UserData:
name: str
# Concrete class binds the specific types to the placeholders:
# T_Obj becomes User, and T_Data becomes UserData.
class UserSerializer(Serializer[User, UserData]):
@override
def serialize(self, item: User) -> UserData:
return UserData(name=user.name)
@override
def deserialize(self, data: UserData) -> User:
return User(name=data.name)Decorators
import time
from collections.abc import Callable
from functools import wraps
def time_it[**P, R](func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start_time = time.perf_counter()
result = func(*args, **kwargs)
end_time = time.perf_counter()
duration = end_time - start_time
print(f"Function '{func.__name__}' took {duration:.4f} seconds")
return result
return wrapperPython 3.11 or earlier
import time
from typing import Callable, TypeVar, ParamSpec
from functools import wraps
P = ParamSpec("P")
R = TypeVar("R")
def time_it(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start_time = time.perf_counter()
result = func(*args, **kwargs)
end_time = time.perf_counter()
duration = end_time - start_time
print(f"Function '{func.__name__}' took {duration:.4f} seconds")
return result
return wrapperType Constraints on Generics
Bounded Generics
Bounded Generics restrict a generic type
Tso it must be a subclass of a specific parent class. Without bounds, a GenericTimplies the object can be anything (an int, a string, a custom object).
from typing import override
class Animal:
def speak(self) -> None:
print("Generic noise")
class Dog(Animal):
@override
def speak(self) -> None:
print("Woof!")
class Cat(Animal):
@override
def speak(self) -> None:
print("Meow!")
def make_noise[T: Animal](creature: T) -> T:
creature.speak()
return creature
make_noise(Animal()) # ✅ OK: is Animal
make_noise(Dog()) # ✅ OK: Dog is an Animal
make_noise(Cat()) # ✅ OK: Cat is an Animal
make_noise(100) # ❌ ERROR: 'int' is not a subclass of AnimalPython 3.11 or earlier syntax
from typing import TypeVar, Generic
from typing_extensions import override
class Animal:
def speak(self) -> None:
print("Generic noise")
class Dog(Animal):
@override
def speak(self) -> None:
print("Woof!")
class Cat(Animal):
@override
def speak(self) -> None:
print("Meow!")
T_Animal = TypeVar("T_Animal", bound=Animal)
def make_noise(creature: T_Animal) -> T_Animal:
creature.speak()
return creature
make_noise(Animal()) # ✅ OK: is Animal
make_noise(Dog()) # ✅ OK: Dog is an Animal
make_noise(Cat()) # ✅ OK: Cat is an Animal
make_noise(100) # ❌ ERROR: 'int' is not a subclass of AnimalStructural Subtyping (Protocol)
Info
Standard classes use “Nominal Subtyping” (inheritance). Protocols allow “Structural Subtyping”. If an object looks like a duck (has the right methods/attributes), a Protocol accepts it as a duck, even if it doesn’t inherit from a specific class.
from typing import Protocol
# This is the "Contract"
class Renderable(Protocol):
def render(self) -> str:
"""Returns the content to be displayed on screen."""
class PDFDocument:
def __init__(self, filename: str):
self.filename = filename
# Implicitly satisfies Renderable
def render(self) -> str:
return f"[PDF] Rendering {self.filename}..."
class ImageFile:
def __init__(self, width: int, height: int):
self.width = width
self.height = height
# Implicitly satisfies Renderable
def render(self) -> str:
return f"[IMG] Displaying {self.width}x{self.height} pixels"
def display_on_screen(item: Renderable) -> None:
print(item.render())
pdf = PDFDocument("report.pdf")
img = ImageFile(1920, 1080)
display_on_screen(pdf) # ✅ OK: Has .render() -> str
display_on_screen(img) # ✅ OK: Has .render() -> str
display_on_screen(100) # ❌ ERROR: int is incompatible with protocol 'Renderable'Notice that PDFDocument and ImageFile does NOT inherit from Renderable. They don’t even know Renderable exists.