Python Type Hints: Write Code That Explains Itself

Python type hints let you declare what types your functions expect and return — without changing how the language actually runs. They make your code clearer to read, easier to maintain, and far less likely to surprise you at 2am when something blows up in production.

Type hints arrived in Python 3.5 through PEP 484. Since then, they have evolved steadily across every major release. Python 3.9 let you use built-in collections like list and dict directly as generic types instead of importing capitalized counterparts from the typing module. Python 3.10 added the pipe operator as a clean shorthand for unions. Python 3.12 rewrote generic syntax entirely, making it far less ceremonial. Each iteration has made annotations feel less like a bolt-on and more like a natural part of the language.

What type hints are (and what they are not)

The most important thing to understand before writing a single annotation is that Python does not enforce type hints at runtime. A function annotated to accept a str will still happily receive an int if you pass one. Nothing breaks. No exception is raised. The interpreter ignores annotations entirely when executing code.

So what do type hints actually do? They communicate intent. They give IDEs like VS Code and PyCharm enough information to offer accurate autocomplete, flag suspicious assignments, and catch mismatches before you ever run the program. They let static analysis tools like mypy scan your codebase and surface bugs that would otherwise only appear at runtime — often in edge cases you did not think to test. They also serve as documentation that cannot go stale the way a comment can, because the annotation is right there in the function signature.

Note

A 2024 survey conducted by JetBrains, Meta, and Microsoft found that 88% of over 1,000 Python developers said they "always" or "often" use type hints. The top reasons were IDE tooling, documentation value, and catching bugs early.

With that foundation in place, the annotation syntax itself is straightforward.

Basic annotations: functions and variables

Function annotations use a colon after each parameter name and an arrow before the return type. The arrow is written as -> and placed between the closing parenthesis and the colon that ends the function signature.

# Parameters get a colon annotation; return type follows ->
def greet(name: str) -> str:
    return f"Hello, {name}!"

def calculate_tax(amount: float, rate: float = 0.2) -> float:
    return amount * rate

def is_valid_token(token: str) -> bool:
    return len(token) == 32

# Functions that return nothing use None as the return type
def log_message(message: str) -> None:
    print(f"[LOG] {message}")

Variable annotations follow the same colon pattern introduced in Python 3.6 through PEP 526. You can annotate a variable with or without assigning a value at the same time.

username: str = "kandi"
age: int = 30
score: float = 98.5
active: bool = True

# You can annotate without assigning — useful in class bodies
# or when the value is set conditionally later
connection_count: int
Pro Tip

Annotating a local variable without assigning it tells the interpreter to treat that name as local to the current scope, even if you assign it inside a conditional branch further down. This prevents accidental references to an outer-scope variable of the same name.

Optional, Union, and the | shorthand

Real functions often accept or return more than one type. A database lookup might return a user object or None if no record exists. A utility function might process either a string or a list of strings. The typing module originally handled these cases with Optional and Union.

from typing import Optional, Union

# Optional[X] is shorthand for Union[X, None]
def find_user(user_id: int) -> Optional[str]:
    # Returns a username string, or None if the user does not exist
    users = {1: "kandi", 2: "alex"}
    return users.get(user_id)

# Union lets you accept multiple distinct types
def display(value: Union[str, int, float]) -> str:
    return str(value)

Python 3.10 introduced a much cleaner way to write these using the pipe operator. The two styles are equivalent; the pipe syntax is generally preferred in any project targeting Python 3.10 or newer.

# Python 3.10+ pipe syntax — no imports needed
def find_user(user_id: int) -> str | None:
    users = {1: "kandi", 2: "alex"}
    return users.get(user_id)

def display(value: str | int | float) -> str:
    return str(value)
Note

Optional[str] and str | None are exactly equivalent. Both tell a type checker that the value can be a string or it can be absent. Choose whichever style your team's minimum Python version supports and use it consistently.

Collections and generics

When a parameter is a list, you rarely mean "any list of anything." You mean a list of strings, or a list of integers. Generic types let you specify what the container holds. Before Python 3.9, you had to import capitalized versions of collection types from the typing module — List, Dict, Tuple, Set — because the built-in lowercase versions did not support subscripting. That requirement was dropped in Python 3.9 through PEP 585.

# Old style (still valid, but verbose — requires import)
from typing import List, Dict, Tuple, Set

def summarize(scores: List[int]) -> Dict[str, float]:
    return {"mean": sum(scores) / len(scores), "count": float(len(scores))}

# Modern style (Python 3.9+, no imports needed for these)
def summarize(scores: list[int]) -> dict[str, float]:
    return {"mean": sum(scores) / len(scores), "count": float(len(scores))}

# Tuple with fixed positions
def get_coordinates() -> tuple[float, float]:
    return (40.7128, -74.0060)

# Tuple of any length, all the same type
def get_flags() -> tuple[bool, ...]:
    return (True, False, True, True)

Dictionaries accept two type parameters — one for keys and one for values. Nested generics are supported and work exactly as you would expect.

# A mapping from username to a list of permission strings
def get_permissions() -> dict[str, list[str]]:
    return {
        "kandi": ["read", "write", "admin"],
        "guest": ["read"],
    }

# A set of unique category tags
def get_tags(post_id: int) -> set[str]:
    tag_map = {1: {"python", "typing", "annotations"}}
    return tag_map.get(post_id, set())

Modern generics: the Python 3.12 syntax

Generic functions and classes allow you to write code that works correctly for multiple types while still giving the type checker enough information to verify usage. Before Python 3.12, writing a generic function required importing TypeVar from the typing module and declaring it separately before using it in the function signature.

# Pre-3.12 style: declare TypeVar, then use it
from typing import TypeVar, Generic
from collections.abc import Sequence

T = TypeVar("T")

def first_item(items: Sequence[T]) -> T:
    return items[0]

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:
        return self._items.pop()

Python 3.12 introduced PEP 695, which adds a dedicated syntax for type parameters using square brackets directly on the function or class definition. The separate TypeVar declaration disappears entirely. The type parameter is scoped to that definition, which prevents a category of subtle naming bugs that could occur with the older approach.

# Python 3.12+ style: type parameter declared inline
from collections.abc import Sequence

def first_item[T](items: Sequence[T]) -> T:
    return items[0]

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

# The type keyword creates a named type alias (also new in 3.12)
type Vector = list[float]
type Matrix = list[Vector]

Python 3.12 also introduced the type soft keyword for creating type aliases explicitly. Previously, aliases were just plain assignments that type checkers had to guess the intent of. The type statement makes the intent unambiguous and produces a proper TypeAliasType object.

Pro Tip

If you are working on a codebase that must run on Python 3.11 or earlier, stick with the TypeVar import approach. The new bracket syntax is a runtime feature, not just a syntax change, so it will raise a SyntaxError on older interpreters.

A comparison of syntax across versions

Feature Pre-3.9 style Modern style
List of strings List[str] (import required) list[str] (3.9+)
Optional value Optional[str] str | None (3.10+)
Multiple types Union[str, int] str | int (3.10+)
Generic function T = TypeVar("T") then def f(x: T) -> T def f[T](x: T) -> T (3.12+)
Type alias MyType = list[str] type MyType = list[str] (3.12+)

Running a type checker with mypy

Annotations on their own are inert. To get the actual error detection, you need to run a type checker. Mypy is the most widely adopted option. It analyzes your annotations without executing the code and reports any mismatches it finds.

# Install mypy
pip install mypy

# Check a single file
mypy myapp.py

# Check a whole package
mypy myapp/

# Strict mode — catches more issues, recommended for new projects
mypy myapp/ --strict

When mypy finds a problem, it prints the file name, line number, and a plain-English description. Common errors include passing a value of the wrong type, forgetting to handle the None branch of an Optional, and returning the wrong type from a function.

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

result = double("ten")  # passing a str where int is expected
print(result)

Running mypy example.py on the code above produces an error on the line where "ten" is passed, telling you that str is not compatible with int. The program would have run without a problem at that line — Python would have returned "tenten" — but the semantic error is caught before you ever run it.

Note

Adding type hints to an existing codebase does not have to be all-or-nothing. Mypy allows gradual adoption — unannotated functions are treated as if they accept and return Any, so you can annotate a few functions at a time and increase coverage incrementally.

Key Takeaways

  1. Type hints are optional and runtime-invisible: Python never enforces annotations when running code. They exist entirely for static analysis tools, IDEs, and human readers.
  2. Use built-in generics on Python 3.9+: Write list[str] and dict[str, int] directly. There is no longer a reason to import List or Dict from typing unless you need to support older Python versions.
  3. Use the pipe operator for unions on Python 3.10+: str | None is cleaner than Optional[str] and reads naturally as English.
  4. Adopt the bracket syntax for generics on Python 3.12+: def f[T](x: T) -> T eliminates the TypeVar import and scopes the type parameter properly.
  5. Run mypy to get real value out of annotations: Without a type checker, annotations are just comments. Running mypy in CI catches bugs that would otherwise survive code review.

Type hints have moved from a niche feature to a mainstream practice because they deliver concrete benefits with low overhead. Starting with just function signatures on new code is enough to see immediate improvements in IDE support and developer confidence. From there, the coverage grows naturally as you work through the codebase.