Python Type Hints — Write Cleaner, Safer Code in 2026

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why This Matters

Python type hints have evolved from optional documentation into a professional standard. They make code self-documenting, enable IDE autocompletion and refactoring, catch bugs before runtime with static analysis tools like mypy and pyright, and power frameworks like FastAPI and Pydantic that use type information to generate validation and API schemas automatically.

In 2026, virtually every serious Python project uses type hints. Codebases at major tech companies enforce them via CI linting. When you apply for Python engineering roles, interviewers expect to see typed function signatures. When you contribute to open source, maintainers will ask you to add types if missing.

This guide covers everything from basic annotations to advanced patterns like Protocol, TypeVar, and TypedDict.

Basic Annotations

# Variables
name: str = "Alice"
age: int = 30
price: float = 9.99
is_active: bool = True
 
# Functions
def greet(name: str) -> str:
    return f"Hello, {name}!"
 
def add(a: int, b: int) -> int:
    return a + b
 
def log(message: str) -> None:  # None = no return value
    print(message)

Built-in Generic Types (Python 3.9+)

Since Python 3.9, you can use built-in types directly as generics without importing from typing.

# Lists, dicts, sets, tuples
def process_names(names: list[str]) -> list[str]:
    return [n.upper() for n in names]
 
def count_words(text: str) -> dict[str, int]:
    return {word: text.count(word) for word in text.split()}
 
def first_last(items: list[int]) -> tuple[int, int]:
    return items[0], items[-1]
 
# Nested
config: dict[str, list[int]] = {"scores": [95, 87, 92]}

Optional and Union

from typing import Optional, Union
 
# Optional[X] means X or None
def find_user(user_id: int) -> Optional[dict]:
    users = {1: {"name": "Alice"}}
    return users.get(user_id)
 
# Same using X | None (Python 3.10+)
def find_user_v2(user_id: int) -> dict | None:
    return None
 
# Union — multiple possible types
def normalize(value: Union[int, float, str]) -> float:
    return float(value)
 
# Python 3.10+ syntax
def normalize_v2(value: int | float | str) -> float:
    return float(value)

TypedDict — Typed Dictionaries

from typing import TypedDict, Required, NotRequired
 
class UserConfig(TypedDict):
    name: str
    email: str
    age: int
    role: NotRequired[str]  # optional key
 
def create_user(config: UserConfig) -> str:
    return f"User: {config['name']} ({config['email']})"
 
user: UserConfig = {"name": "Alice", "email": "alice@example.com", "age": 30}
print(create_user(user))

Dataclasses with Type Hints

from dataclasses import dataclass, field
from typing import Optional
 
@dataclass
class Product:
    id: int
    name: str
    price: float
    tags: list[str] = field(default_factory=list)
    description: Optional[str] = None
 
    def discounted_price(self, rate: float) -> float:
        return self.price * (1 - rate)
 
p = Product(id=1, name="Widget", price=29.99, tags=["sale", "new"])
print(p.discounted_price(0.1))  # 26.991

Callable and Higher-Order Functions

from typing import Callable
 
def apply(func: Callable[[int], int], value: int) -> int:
    return func(value)
 
def make_adder(n: int) -> Callable[[int], int]:
    def adder(x: int) -> int:
        return x + n
    return adder
 
add_five = make_adder(5)
print(apply(add_five, 10))  # 15

TypeVar — Generic Functions

from typing import TypeVar
 
T = TypeVar("T")
 
def first(items: list[T]) -> T:
    """Return the first element of any list."""
    return items[0]
 
print(first([1, 2, 3]))        # 1 (int)
print(first(["a", "b", "c"]))  # 'a' (str)

Protocol — Structural Typing

from typing import Protocol
 
class Drawable(Protocol):
    def draw(self) -> str:
        ...
 
class Circle:
    def draw(self) -> str:
        return "Drawing circle"
 
class Square:
    def draw(self) -> str:
        return "Drawing square"
 
def render(shape: Drawable) -> None:
    print(shape.draw())
 
# Both work without explicitly inheriting from Drawable
render(Circle())   # Drawing circle
render(Square())   # Drawing square

Type Guards

from typing import TypeGuard
 
def is_string_list(val: list) -> TypeGuard[list[str]]:
    return all(isinstance(x, str) for x in val)
 
items: list[object] = ["a", "b", "c"]
if is_string_list(items):
    # items is now list[str] in this block
    print(items[0].upper())

Running mypy

pip install mypy
mypy main.py --strict

Common Mistakes

  • Using list instead of list[str] for typed lists (missing the generic)
  • Using Optional[str] when str | None is clearer in Python 3.10+
  • Annotating -> None on functions that actually return a value
  • Using Any everywhere to silence type errors without fixing them
  • Not running a type checker — annotations alone don't enforce types

Best Practices

  • Use str | None over Optional[str] in Python 3.10+ codebases
  • Run mypy --strict or pyright in CI to enforce type correctness
  • Use Protocol for duck-typed interfaces instead of abstract base classes
  • Use dataclass or Pydantic BaseModel for structured data over plain dicts
  • Start with reveal_type(expr) in mypy to understand inferred types

Key Takeaways

  • Type hints are annotations — they do not enforce types at runtime without libraries like Pydantic
  • Use built-in generics: list[str], dict[str, int], tuple[int, ...] in Python 3.9+
  • Optional[T] is equivalent to T | None — prefer the latter in Python 3.10+
  • TypedDict types dictionary shapes; dataclass creates typed data objects
  • Protocol enables structural (duck-typed) interfaces for more flexible type checking
  • mypy and pyright are the main tools for static type analysis
  • FastAPI and Pydantic leverage type hints at runtime to generate validation and documentation

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading