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 TypeConcrete TypesWhat your code needs…
Iterable[T]list, tuple, set, str, dictLoop 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]listSame 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]dictSame 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]setSame 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_emails
Sequence 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 None
Python 3.9 or earlier
from typing import Optional
 
x: Optional[str] = "something" if some_condition() else None

Info

Optional[X] is the preferred shorthand for Union[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 Callable type 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 @overload signatures 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.Self to 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_000
Python 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_000

Preventing 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 Any sparingly. It effectively turns off the type checker for that part of the code. Consider using object instead.

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, object is the root base class for all types. Integers, strings, functions, and custom classes all inherit from it. This makes object the 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

T stands 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 int
Python 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 int

Multiple 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 wrapper
Python 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 wrapper

Type Constraints on Generics

Bounded Generics

Bounded Generics restrict a generic type T so it must be a subclass of a specific parent class. Without bounds, a Generic T implies 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 Animal
Python 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 Animal

Structural 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.