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.

Type Hints Are Not Enforced at Runtime

Let’s start with the single most important property of Python type hints: unlike Java or TypeScript, they are not checked at runtime. The CPython interpreter merely stores type information in a function’s __annotations__ attribute; it never cross-checks that information against the actual arguments at call time. A type hint is “executable documentation” — whether it is ever validated is entirely up to an external static analyzer (mypy, pyright) or a runtime validation library (pydantic and similar).

Let’s see this concretely.

# double.py
def double(x: int) -> int:
    return x * 2

# The hint says "x: int", but Python does not check that str violates it
result = double("ab")
print(repr(result))
print(type(result))
$ python3 double.py
'abab'
<class 'str'>

double("ab") runs without raising any exception and silently returns "abab" — a return value that violates the type hint (because str * 2 is interpreted as string repetition). The signature declares x: int -> int, but nothing checks that at runtime, so the bug slips through quietly.

Running mypy on the same file catches the mismatch before execution:

$ mypy double.py
double.py:6: error: Argument 1 to "double" has incompatible type "str"; expected "int"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

(Verified with mypy 2.3.0 / Python 3.14.6 — all output above is from an actual run.)

That difference is the essence of type hints: they are input data for static analysis tools, and the Python runtime reads but never validates them. The diagram below summarizes the two paths.

Flowchart showing that source code with a type hint follows two separate paths: the CPython runtime ignores the hint and executes anyway, while a static checker such as mypy or pyright cross-checks call sites against the hint and reports an error

If you want type hints to act as a runtime guarantee, you need one of the following:

  • Wire a static checker (mypy, pyright) into CI so violations are caught before merge
  • Validate data boundaries (API input/output, etc.) with a runtime validation library such as pydantic
  • Read type information with typing.get_type_hints() and manually check it with isinstance() (expensive to maintain — usually a last resort)

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()

Variance: Why list[int] Is Not a list[float]

Using generic types safely requires understanding variance. Arithmetically, int can stand in for float, but list[int] cannot be passed where list[float] is expected. The reason: a mutable container like list is invariant.

# variance_demo.py
from typing import Sequence


def sum_as_float(values: list[float]) -> float:
    return sum(values)


def sum_seq_as_float(values: Sequence[float]) -> float:
    return sum(values)


ints: list[int] = [1, 2, 3]

# list[int] is not a list[float] (list is invariant)
sum_as_float(ints)  # mypy error

# Sequence[int] is compatible with Sequence[float] (Sequence is covariant)
sum_seq_as_float(ints)  # OK
$ mypy variance_demo.py
variance_demo.py:16: error: Argument 1 to "sum_as_float" has incompatible type "list[int]"; expected "list[float]"  [arg-type]
variance_demo.py:16: note: "list" is invariant -- see https://mypy.readthedocs.io/en/stable/common_issues.html#variance
variance_demo.py:16: note: Consider using "Sequence" instead, which is covariant
Found 1 error in 1 file (checked 1 source file)

Just as mypy’s own note suggests, sum_seq_as_float (whose parameter type is Sequence[float]) accepts the same ints without error. Why does this difference exist?

  • Invariant: list[int] cannot substitute for list[float]. list supports writing (e.g. append), so if this substitution were allowed, the callee could do values.append(1.5), injecting a float into what the caller still believes is a list[int]. Code that later assumes every element is an int would then receive 1.5 and break at runtime. mypy forbids the assignment outright to prevent this.
  • Covariant: Sequence[int] can substitute for Sequence[float] (in a read-only position). Sequence has no write methods like append — it only supports reading — so writing can never corrupt the type, and the intuitive relationship (“an int may be read as a float”) holds safely.
  • Contravariant: The reverse relationship applies to positions like function arguments. A function that accepts a more general type can substitute for one that requires a more specific type (e.g. Callable[[object], None] can be used where Callable[[int], None] is required). This is expressed by declaring a TypeVar with contravariant=True.
VarianceMeaningExamplesWritable
InvariantNo substitution relationship between A[int] and A[float]list[T], dict[K, V], set[T]Yes
CovariantIf int is a subtype of float, A[int] is a subtype of A[float]Sequence[T], Tuple[T, ...], FrozenSet[T]No (read-only)
ContravariantThe reverse relationshipCallable argument positionsN/A (function argument)

As a practical rule of thumb, if a function only reads from a container without mutating it, prefer Sequence (a read-only, covariant collection) over list in the type hint — it produces a more flexible, easier-to-call API. Reserve list or MutableSequence for cases that genuinely need to mutate the container.

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

What mypy --strict Actually Catches

The following code looks like it might just work — but running mypy --strict on it surfaces three representative categories of error.

# strict_demo.py
from typing import Optional


# 1. Missing return type annotation
def get_display_name(user_id: int):
    if user_id == 1:
        return "Alice"
    return "Guest"


# 2. Incompatible assignment
count: int = "42"


# 3. Optional used without a None check
def to_upper(name: Optional[str]) -> str:
    return name.upper()
$ mypy --strict strict_demo.py
strict_demo.py:6: error: Function is missing a return type annotation  [no-untyped-def]
strict_demo.py:13: error: Incompatible types in assignment (expression has type "str", variable has type "int")  [assignment]
strict_demo.py:18: error: Item "None" of "str | None" has no attribute "upper"  [union-attr]
Found 3 errors in 1 file (checked 1 source file)

(Verified with mypy 2.3.0 / Python 3.14.6 — this is real output from an actual run.)

What causes each error, and how to fix it:

LineError codeCauseFix
5no-untyped-def--strict enables disallow_untyped_defs, so a return type is required even when inferableAdd -> str:
12assignmentThe int-typed variable count is assigned a strChange to count: int = 42, or widen the declared type deliberately
17union-attrOptional[str] (str | None) could be None, but .upper() is called unconditionallyAdd a guard such as if name is None: return ""

Any “Disables” Type Checking — Unlike object

Any is a special type that accepts anything, and mypy permits every operation on an Any-typed value without question. Worse, this “unchecked” status propagates through assignments and function calls, so once Any enters a codebase, type checking is effectively disabled for everything downstream of it. This is a common, gradual way large codebases lose the benefits of typing.

object, by contrast, is the strictest possible “accepts anything” type — it is the parent of every type, but only exposes the common interface (__str__, __eq__, etc.). Both look like “an argument that accepts anything,” but mypy treats Any and object completely differently.

# any_vs_object.py
from typing import Any


def use_any(value: Any) -> int:
    # value is Any, so calling a nonexistent method is never flagged by mypy
    return value.nonexistent_method()


def use_object(value: object) -> int:
    # value is object, so calling a nonexistent method IS flagged by mypy
    return value.nonexistent_method()
$ mypy any_vs_object.py
any_vs_object.py:12: error: "object" has no attribute "nonexistent_method"  [attr-defined]
Found 1 error in 1 file (checked 1 source file)

Line 7 in use_any clearly calls a method that doesn’t exist, yet mypy reports zero errors for it. The identical call pattern in use_object (line 12) is correctly caught. When you need a parameter that can accept “anything” but still want type safety inside the function, reach for object instead of Any, and narrow it with isinstance() as needed.

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

Typing Additions in Recent Python Versions

The typing-related spec has kept evolving actively since Python 3.12. Every example below was actually verified to run on Python 3.14.6.

VersionPEPWhat it adds
3.12PEP 695New generic syntax (def first[T](...)), type Alias = ... statement (lazily evaluated TypeAliasType)
3.13PEP 696Default values for type parameters (e.g. class Box[T = str]: ...)
3.13PEP 705Read-only TypedDict keys via ReadOnly[...]
3.13PEP 742TypeIs — a new type guard that narrows more intuitively than TypeGuard
3.14PEP 649 / PEP 749Deferred evaluation of annotations — forward references now work without from __future__ import annotations

The type statement (PEP 695) replaces TypeAlias with new syntax whose right-hand side is evaluated lazily (which makes it robust to forward references).

# Python 3.12+ (PEP 695): type alias statement
type StringList = list[str]
type Coordinate = tuple[float, float]

def normalize(values: StringList) -> StringList:
    return [v.strip() for v in values]

print(normalize([" a ", "b "]))  # ['a', 'b']

TypeIs (PEP 742) gives an isinstance-based narrowing function a precise return type. Variance matters here too: because Sequence is covariant, narrowing Sequence[object] to Sequence[str] works cleanly, but narrowing list[object] to list[str] the same way triggers a mypy error due to list’s invariance.

# Python 3.13+ (PEP 742): narrowing with TypeIs
from typing import TypeIs, Sequence

def is_str_seq(val: Sequence[object]) -> TypeIs[Sequence[str]]:
    return all(isinstance(x, str) for x in val)

items: Sequence[object] = ["a", "b"]
if is_str_seq(items):
    print("narrowed to Sequence[str]:", items)
$ mypy pep_features_final.py
Success: no issues found in 1 source file

ReadOnly (PEP 705) expresses per-key immutability on a TypedDict. Attempting to reassign such a key is caught by mypy with a dedicated error code.

# Python 3.13+ (PEP 705): read-only TypedDict key
from typing import TypedDict, ReadOnly

class Config(TypedDict):
    name: str
    version: ReadOnly[str]

def bump(c: Config) -> None:
    c["version"] = "2.0"  # writing to a ReadOnly key
$ mypy readonly_check.py
readonly_check.py:8: error: ReadOnly TypedDict key "version" TypedDict is mutated  [typeddict-readonly-mutated]
Found 1 error in 1 file (checked 1 source file)

(All three code examples and mypy outputs above were actually run with mypy 2.3.0 / Python 3.14.6.)

Python 3.14 defers annotation evaluation for functions and classes (PEP 649 / PEP 749), so forward references — such as a not-yet-defined class name — can now appear in type hints without needing from __future__ import annotations. This can affect libraries that read annotations at runtime (dataclasses, pydantic, and similar), so consult the annotationlib module documentation before migrating.

References