Skip to content
Python Type Hints — Complete Guide with mypy

Python Type Hints — Complete Guide with mypy

DodaTech Updated Jun 15, 2026 6 min read

Type hints (also called type annotations) let you declare the expected types of variables, function parameters, and return values. Python ignores them at runtime, but static type checkers like mypy use them to catch bugs before your code runs.

What You’ll Learn

  • Basic type hints: int, str, float, bool, list, dict, tuple
  • Optional, Union, Any, Literal — expressing flexibility
  • TypedDict for structured dictionaries
  • Protocol for structural subtyping (duck typing)
  • mypy configuration and type narrowing

Why Type Hints Matter

Durga Antivirus Pro has hundreds of functions across thousands of lines — type hints catch mismatched arguments before they cause crashes in production. DodaZIP uses typed interfaces for compression modules, making the code self-documenting. Without type hints, another developer (or you, six months later) has to read every function body to understand what it expects.

    flowchart LR
    A["Functions"] --> B["OOP"]
    B --> C["Type Hints"]
    C --> D["Testing"]
    D --> E["Async"]
    A:::done --> B:::done --> C:::current
    style A fill:#2563eb,stroke:#2563eb,color:#fff
    style B fill:#2563eb,stroke:#2563eb,color:#fff
    style C fill:#2563eb,stroke:#2563eb,color:#fff
    style D fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style E fill:#f1f5f9,stroke:#94a3b8,color:#64748b
  
Prerequisite: Understand Python functions and Python classes. Review https://tutorials.dodatech.com/programming-languages/python/py-functions/ first.

Basic Type Hints

def greet(name: str, age: int) -> str:
    return f"{name} is {age} years old"

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

def process_items(items: list[str]) -> dict[str, int]:
    return {item: len(item) for item in items}

The syntax is variable: type for parameters and -> type for return values. Standard types (int, str, float, bool) are built-in. Container types (list, dict, tuple) need imported generics in Python 3.8-3.9, or work natively from 3.9+.

Optional, Union, Any

from typing import Optional, Union, Any

# Optional — value could be None
def find_user(user_id: int) -> Optional[str]:
    users = {1: "Alice", 2: "Bob"}
    return users.get(user_id)  # Returns str or None

# Union — value could be one of several types
def process(value: Union[int, str]) -> str:
    if isinstance(value, int):
        return f"Number: {value}"
    return f"String: {value}"

# Any — no type checking (use sparingly)
def log(message: Any) -> None:
    print(message)

Python 3.10+ introduced | syntax: str | None instead of Optional[str], and int | str instead of Union[int, str].

Generics: List, Dict, Tuple, Set

from typing import List, Dict, Tuple, Set  # Python 3.8-3.9
# Python 3.9+: use built-in list, dict, tuple, set

names: list[str] = ["Alice", "Bob"]
scores: dict[str, int] = {"Alice": 95, "Bob": 87}
coordinates: tuple[float, float] = (10.0, 20.0)
unique_ids: set[int] = {1, 2, 3}

def get_averages(data: list[dict[str, float]]) -> dict[str, float]:
    result = {}
    for entry in data:
        for key, value in entry.items():
            result.setdefault(key, []).append(value)
    return {k: sum(v)/len(v) for k, v in result.items()}

TypedDict — Structured Dictionaries

TypedDict gives dictionaries with fixed keys and specific types:

from typing import TypedDict

class User(TypedDict):
    id: int
    name: str
    email: str
    is_active: bool

def create_user(name: str, email: str) -> User:
    return {
        "id": 1,
        "name": name,
        "email": email,
        "is_active": True,
    }

user = create_user("Alice", "alice@example.com")
print(user["name"])  # Alice
# user["missing"]    # mypy error: TypedDict 'User' has no key 'missing'

Protocol — Structural Subtyping

Protocol defines an interface by its structure (duck typing), not inheritance:

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(obj: Drawable) -> None:
    print(obj.draw())

render(Circle())  # Drawing circle
render(Square())  # Drawing square

Any object with a draw() -> str method satisfies Drawable — no inheritance required.

Type Narrowing

Type checkers narrow types inside conditionals automatically:

from typing import Optional

def describe(value: Optional[int]) -> str:
    if value is None:
        return "No value provided"
    # mypy knows value is int here
    if value < 0:
        return f"Negative: {value}"
    return f"Positive: {value}"

from typing import Union

def process(value: int | str) -> str:
    match value:
        case int():
            return f"Integer: {value * 2}"
        case str():
            return f"String: {value.upper()}"

Running mypy

Install: pip install mypy

mypy my_script.py

Sample output:

my_script.py:10: error: Argument 1 to "greet" has incompatible type "int"; expected "str"
my_script.py:15: error: Incompatible return value type (got "int", expected "str")
Found 2 errors in 1 file (checked 1 source file)

Create a mypy.ini or pyproject.toml config:

[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = true
warn_unused_configs = true

Real-World Example: Type-Hinted Data Processor

from typing import TypedDict, Optional

class UserData(TypedDict):
    user_id: int
    name: str
    email: str

class ProcessedResult(TypedDict):
    user_id: int
    name_length: int
    domain: Optional[str]

def extract_domain(email: str) -> Optional[str]:
    """Extract domain from email, return None if invalid."""
    parts = email.split("@")
    if len(parts) != 2:
        return None
    return parts[1]

def process_users(users: list[UserData]) -> list[ProcessedResult]:
    results: list[ProcessedResult] = []
    for user in users:
        results.append({
            "user_id": user["user_id"],
            "name_length": len(user["name"]),
            "domain": extract_domain(user["email"]),
        })
    return results

users: list[UserData] = [
    {"user_id": 1, "name": "Alice", "email": "alice@example.com"},
    {"user_id": 2, "name": "Bob", "email": "bob@example.org"},
]
print(process_users(users))

Common Mistakes

1. Using List Instead of list (Python 3.9+)

# Old style (3.8-3.9)
from typing import List
items: List[str] = []

# New style (3.9+)
items: list[str] = []

2. Forgetting to Annotate Return Types

Always annotate return types — they help mypy catch when you accidentally return the wrong type.

3. Overusing Any

def process(data: Any) -> Any:  # Defeats the purpose!
    return data

Fix: Be specific. If you accept multiple types, use Union or overloads.

4. Using Optional[X] When the Value Is Never None

def get_positive(n: Optional[int]) -> int:  # Wrong — n isn't optional
    return abs(n)

5. Ignoring mypy Errors

Commenting # type: ignore should be the exception, not the rule. Fix the type issues instead.

Practice Questions

1. What’s the difference between list[int] (3.9+) and typing.List[int]?
Same thing. The built-in syntax is preferred from Python 3.9+.

2. When should you use Protocol vs abstract base classes?
Use Protocol when you want structural subtyping (duck typing). Use ABC when you want explicit inheritance hierarchies.

3. What does Optional[str] mean?
str | None — the value can be a string or None.

4. How does type narrowing work in mypy?
mypy analyzes control flow — after an if value is None check, it knows value is not None in the else branch.

Challenge: Write a function merge_dicts(a: dict[str, int], b: dict[str, int]) -> dict[str, int] with full type hints that adds values for overlapping keys.

Solution
def merge_dicts(a: dict[str, int], b: dict[str, int]) -> dict[str, int]:
    result = a.copy()
    for key, value in b.items():
        result[key] = result.get(key, 0) + value
    return result

Mini Project: Type-Hinted API Client

from typing import TypedDict, Optional
import json

class Post(TypedDict):
    userId: int
    id: int
    title: str
    body: str

class APIResult(TypedDict):
    success: bool
    data: Optional[list[Post]]
    error: Optional[str]

def fetch_posts(api_url: str) -> APIResult:
    """Simulate fetching posts from an API."""
    sample_data: list[Post] = [
        {"userId": 1, "id": 1, "title": "Hello", "body": "World"},
        {"userId": 1, "id": 2, "title": "Python", "body": "Typing"},
    ]
    return {"success": True, "data": sample_data, "error": None}

def count_user_posts(posts: list[Post]) -> dict[int, int]:
    """Count how many posts each user has."""
    counts: dict[int, int] = {}
    for post in posts:
        counts[post["userId"]] = counts.get(post["userId"], 0) + 1
    return counts

result = fetch_posts("https://jsonplaceholder.typicode.com/posts")
if result["success"] and result["data"] is not None:
    print(count_user_posts(result["data"]))

Expected output: {1: 2}

What’s Next

Type hints make your code safer and self-documenting. Next, learn context managers for resource management.

TopicDescriptionLink
Python Context ManagersWith-statement patternshttps://tutorials.dodatech.com/programming-languages/python/py-context-managers/
Python Testingpytest and mockinghttps://tutorials.dodatech.com/programming-languages/python/py-testing/
Python GeneratorsFoundation of iterationhttps://tutorials.dodatech.com/programming-languages/python/py-generators/

Practice tip: Add type hints to your BankAccount class and run mypy on it. Fix every error until mypy reports success.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro