Python Type Hints Practical Guide: From Basics to mypy

A comprehensive guide to Python type hints from basics to advanced usage including typing module, generics, Protocol, and static type checking with mypy.

Introduction

Python is a dynamically typed language, but since Python 3.5, type hints allow you to add static type information to your code. Type hints do not affect runtime behavior, but they provide several benefits:

  • Improved readability: Function argument and return types are immediately clear
  • Better IDE support: More accurate auto-completion and refactoring
  • Early bug detection: Static type checkers like mypy catch errors before execution
  • Self-documenting code: Types serve as inline documentation

This article covers type hints from basics to static type checking with mypy.

Basic Type Hints

Variable Type Hints

Python 3.6+ supports type hints on variables.

name: str = "hello"
age: int = 30
height: float = 175.5
is_active: bool = True

Assigning a value that doesn’t match the type hint won’t cause a runtime error, but mypy will flag it.

name: str = 123  # mypy: Incompatible types in assignment

Function Type Hints

The most common use is annotating function arguments and return values.

def greet(name: str) -> str:
    return f"Hello, {name}"

def add(a: int, b: int) -> int:
    return a + b

Functions That Return Nothing

Use -> None for functions without a return value.

def log_message(message: str) -> None:
    print(f"[LOG] {message}")

Default Arguments

When using default arguments, place the type hint before the =.

def greet(name: str, greeting: str = "Hello") -> str:
    return f"{greeting}, {name}"

Collection Types

Python 3.9+ Built-in Generics

From Python 3.9 onward, built-in types can be used directly as generics.

# Python 3.9+
names: list[str] = ["Alice", "Bob"]
scores: dict[str, int] = {"Alice": 95, "Bob": 87}
coordinates: tuple[float, float] = (35.68, 139.76)
unique_tags: set[str] = {"python", "typing"}

Use ... for variable-length tuples.

# A tuple of arbitrary length containing ints
numbers: tuple[int, ...] = (1, 2, 3, 4, 5)

Python 3.8 and Earlier (typing Module)

Before Python 3.9, you need to import from the typing module.

from typing import List, Dict, Tuple, Set

names: List[str] = ["Alice", "Bob"]
scores: Dict[str, int] = {"Alice": 95}
coordinates: Tuple[float, float] = (35.68, 139.76)
unique_tags: Set[str] = {"python", "typing"}

Recommendation: On Python 3.9+, prefer built-in types (list, dict, tuple, set). The typing.List forms are being deprecated.

Nested Collections

Collection types can be nested.

# Dict with string keys and list of strings as values
user_tags: dict[str, list[str]] = {
    "alice": ["python", "ml"],
    "bob": ["go", "docker"],
}

# List of tuples
points: list[tuple[int, int]] = [(0, 0), (1, 2), (3, 4)]

Optional and Union

Optional

Use when a value can be None.

from typing import Optional

# Python 3.9 and earlier
def find_user(user_id: int) -> Optional[str]:
    if user_id == 1:
        return "Alice"
    return None

Python 3.10+ supports the | operator for a more concise syntax.

# Python 3.10+
def find_user(user_id: int) -> str | None:
    if user_id == 1:
        return "Alice"
    return None

Union

Use when a value can be one of multiple types.

from typing import Union

# Python 3.9 and earlier
def process(value: Union[int, str]) -> str:
    return str(value)

# Python 3.10+
def process(value: int | str) -> str:
    return str(value)

Optional Is Syntactic Sugar for Union

Optional[X] is exactly equivalent to Union[X, None].

# All of these are equivalent
from typing import Optional, Union

x: Optional[str]
x: Union[str, None]
x: str | None  # Python 3.10+

TypedDict and dataclass

TypedDict

Define the types of dictionary keys and values. Useful for API responses and configuration.

from typing import TypedDict

class UserProfile(TypedDict):
    name: str
    age: int
    email: str
    is_active: bool

def get_user() -> UserProfile:
    return {
        "name": "Alice",
        "age": 30,
        "email": "alice@example.com",
        "is_active": True,
    }

user = get_user()
print(user["name"])  # "Alice" — IDE provides auto-completion

For optional keys, use total=False or per-key NotRequired (3.11+).

from typing import TypedDict, NotRequired  # Python 3.11+

class UserProfile(TypedDict):
    name: str
    age: int
    nickname: NotRequired[str]  # Optional key

dataclass

For structured data, dataclass is often a better fit. Unlike TypedDict, it supports attribute access (.name).

from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int
    email: str
    is_active: bool = True

user = User(name="Alice", age=30, email="alice@example.com")
print(user.name)  # "Alice"
print(user)       # User(name='Alice', age=30, email='alice@example.com', is_active=True)

Use frozen=True for immutability.

@dataclass(frozen=True)
class Point:
    x: float
    y: float

p = Point(1.0, 2.0)
p.x = 3.0  # FrozenInstanceError

TypedDict vs dataclass

AspectTypedDictdataclass
Data structuredictClass instance
Accessd["key"]obj.attr
JSON compatDirectly usable as dictConversion needed
ImmutabilityNot supportedfrozen=True
Use caseAPI responses, configDomain models, value objects

Generics

Generic Functions with TypeVar

Abstract over types to create reusable functions.

from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:
    return items[0]

# Type inference works
name = first(["Alice", "Bob"])   # str
number = first([1, 2, 3])        # int

You can constrain a TypeVar.

from typing import TypeVar

Number = TypeVar("Number", int, float)

def double(x: Number) -> Number:
    return x * 2

double(5)      # OK: int
double(3.14)   # OK: float
double("hi")   # mypy error

Python 3.12+ Syntax

Python 3.12 introduced a new syntax that eliminates the need to explicitly define TypeVar.

# Python 3.12+
def first[T](items: list[T]) -> T:
    return items[0]

Generic Classes

from typing import TypeVar, Generic

T = TypeVar("T")

class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        if not self._items:
            raise IndexError("Stack is empty")
        return self._items.pop()

    def is_empty(self) -> bool:
        return len(self._items) == 0

# Type is determined at usage
int_stack = Stack[int]()
int_stack.push(1)
int_stack.push(2)
value: int = int_stack.pop()  # 2

str_stack = Stack[str]()
str_stack.push("hello")

In Python 3.12+, classes also support the new syntax.

# Python 3.12+
class Stack[T]:
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        return self._items.pop()

Protocol (Structural Subtyping)

Type-Safe Duck Typing

Protocol (Python 3.8+) brings type safety to Python’s duck typing philosophy.

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> str:
        ...

class Circle:
    def draw(self) -> str:
        return "Drawing a circle"

class Square:
    def draw(self) -> str:
        return "Drawing a square"

def render(shape: Drawable) -> None:
    print(shape.draw())

# Circle and Square don't explicitly inherit Drawable,
# but they pass type checking because they have a draw() method
render(Circle())  # OK
render(Square())  # OK

Protocol vs ABC

Protocol uses structural subtyping and does not require explicit inheritance.

from abc import ABC, abstractmethod
from typing import Protocol

# ABC: Explicit inheritance required (nominal subtyping)
class DrawableABC(ABC):
    @abstractmethod
    def draw(self) -> str:
        ...

class CircleABC(DrawableABC):  # Must inherit
    def draw(self) -> str:
        return "circle"

# Protocol: No inheritance needed (structural subtyping)
class DrawableProtocol(Protocol):
    def draw(self) -> str:
        ...

class CircleProtocol:  # No inheritance. Just having draw() is enough
    def draw(self) -> str:
        return "circle"

Practical Example: Logger Interface

from typing import Protocol

class Logger(Protocol):
    def log(self, message: str) -> None:
        ...

class ConsoleLogger:
    def log(self, message: str) -> None:
        print(f"[CONSOLE] {message}")

class FileLogger:
    def __init__(self, path: str) -> None:
        self.path = path

    def log(self, message: str) -> None:
        with open(self.path, "a") as f:
            f.write(f"{message}\n")

def process_data(data: list[int], logger: Logger) -> int:
    logger.log(f"Processing {len(data)} items")
    result = sum(data)
    logger.log(f"Result: {result}")
    return result

# Both satisfy the Logger Protocol
process_data([1, 2, 3], ConsoleLogger())
process_data([1, 2, 3], FileLogger("/tmp/app.log"))

Static Type Checking with mypy

Installation and Basic Usage

pip install mypy

# Check a single file
mypy script.py

# Check an entire directory
mypy src/

# Check a package
mypy -p mypackage

Errors mypy Detects

# example.py
def greet(name: str) -> str:
    return f"Hello, {name}"

result: int = greet("Alice")  # error: Incompatible types in assignment
greet(123)                     # error: Argument 1 has incompatible type "int"
$ mypy example.py
example.py:4: error: Incompatible types in assignment
    (expression has type "str", variable has type "int")
example.py:5: error: Argument 1 to "greet" has incompatible type "int";
    expected "str"
Found 2 errors in 1 file (checked 1 source file)

Configuration

Manage settings in pyproject.toml or mypy.ini.

# pyproject.toml
[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
strict_equality = true

# For third-party libraries without type stubs
[[tool.mypy.overrides]]
module = ["some_untyped_library.*"]
ignore_missing_imports = true

–strict Mode

--strict enables the most rigorous checks. Recommended for new projects.

mypy --strict src/

Key options enabled by --strict:

OptionDescription
disallow_untyped_defsForbid functions without type hints
disallow_any_genericsRequire list[int] instead of bare list
warn_return_anyWarn on functions returning Any
no_implicit_reexportForbid implicit re-exports
strict_equalityWarn on == between incompatible types

Common Errors and Fixes

# 1. Missing return statement
def get_name(user_id: int) -> str:
    if user_id == 1:
        return "Alice"
    # error: Missing return statement
    # Fix: return "" or raise ValueError()

# 2. Incompatible types in assignment
data: list[int] = []
data.append("hello")  # error
# Fix: data: list[int | str] = [] or data.append(123)

# 3. Item "None" of "Optional[str]" has no attribute "upper"
def process(name: str | None) -> str:
    return name.upper()  # error: name could be None
    # Fix: Add a None check
    # if name is None:
    #     return ""
    # return name.upper()

# 4. type: ignore for specific lines (last resort)
result = some_untyped_function()  # type: ignore[no-untyped-call]

Type Hints Best Practices

PracticeDescription
Start with public APIsBegin by annotating function signatures (arguments and return types)
Avoid AnyAny disables type checking. Use specific types or object instead
Be explicit about OptionalAlways use Optional / X | None when None is a possible return value
Constrain TypeVarPrefer bound or constrained types over unconstrained TypeVar
Adopt incrementallyApply --disallow-untyped-defs gradually to existing projects
Comment type: ignoreAdd reasons: # type: ignore[arg-type] # legacy API
Integrate mypy in CIRun mypy in pre-commit hooks or CI pipelines
Include py.typed markerAdd a py.typed file when publishing typed libraries

References