In the previous tutorial, we learned about context managers. Now let’s take a deep dive into type hints — annotations that make your code safer, more readable, and easier to refactor.

We have been using basic type hints since Tutorial #3. Now it is time to learn the advanced types: Optional, Union, Literal, Annotated, TypeAlias, Callable, and more. By the end of this tutorial, you will know how to annotate any Python code and use tools like mypy to catch bugs before runtime.

Why Type Hints?

Type hints serve three purposes:

  1. Documentation — they tell readers what a function expects and returns. No need to read the function body to understand the interface.
  2. IDE support — VS Code and PyCharm use them for autocomplete, refactoring, and inline error detection. This makes you faster.
  3. Static analysis — tools like mypy catch type errors before you run the code. You find bugs at development time, not in production.

Python ignores type hints at runtime. They have zero performance cost. They are purely for developers and tools. You can add them gradually — you do not need to annotate every file at once.

Basic Type Hints Review

You already know these from earlier tutorials:

def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b

def greet(name: str, greeting: str = "Hello") -> str:
    """Return a greeting message."""
    return f"{greeting}, {name}!"

def is_even(n: int) -> bool:
    """Check if a number is even."""
    return n % 2 == 0

The syntax: parameter: type for inputs, -> type for the return value. Default values come after the type hint: greeting: str = "Hello".

Collection Types

Since Python 3.9, you can use built-in collection types directly in type hints:

def sum_list(numbers: list[int]) -> int:
    """Sum a list of integers."""
    return sum(numbers)

def merge_dicts(a: dict[str, int], b: dict[str, int]) -> dict[str, int]:
    """Merge two dictionaries."""
    return {**a, **b}

def unique_items(items: list[str]) -> set[str]:
    """Return unique items from a list."""
    return set(items)

def first_and_last(items: list[str]) -> tuple[str, str]:
    """Return the first and last items."""
    return items[0], items[-1]

Here is a reference of collection types:

Type hintMeaning
list[int]List of integers
dict[str, int]Dict with string keys and int values
set[str]Set of strings
tuple[str, int]Tuple with exactly a string and an int
tuple[int, ...]Tuple of any number of ints
list[dict[str, int]]List of dicts

Before Python 3.9, you had to import these from typing: from typing import List, Dict, Set, Tuple. The modern syntax (lowercase, no import) is preferred.

Optional and Union

Optional: Value or None

Optional[str] means “string or None”. Use it when a function might return nothing:

from typing import Optional

def find_user(user_id: int, users: dict[int, str]) -> Optional[str]:
    """Find a user by ID. Returns None if not found."""
    return users.get(user_id)

result = find_user(1, {1: "Alex", 2: "Sam"})  # "Alex"
result = find_user(99, {1: "Alex", 2: "Sam"})  # None

Optional[str] is equivalent to str | None in Python 3.10+.

Union: Multiple Possible Types

Union[int, float, str] means “any of these types”:

from typing import Union

def parse_value(value: str) -> Union[int, float, str]:
    """Parse a string to int, float, or return as string."""
    try:
        return int(value)
    except ValueError:
        pass
    try:
        return float(value)
    except ValueError:
        return value

print(parse_value("42"))     # 42 (int)
print(parse_value("3.14"))   # 3.14 (float)
print(parse_value("hello"))  # "hello" (str)

Modern Union Syntax (Python 3.10+)

Use | instead of Union and Optional:

# Modern syntax — clean and readable
def process_input(data: str | int | None) -> str:
    if data is None:
        return "No data"
    return str(data)

def find_user(user_id: int) -> str | None:
    ...

The | syntax is recommended for Python 3.10+. It is shorter and more readable.

Literal: Restrict to Specific Values

Literal restricts a parameter to specific values. This is like an enum but simpler:

from typing import Literal

def set_color(color: Literal["red", "green", "blue"]) -> str:
    """Set a color. Only accepts 'red', 'green', or 'blue'."""
    return f"Color set to {color}"

def set_log_level(level: Literal["debug", "info", "warning", "error"]) -> str:
    """Set the log level."""
    return f"Log level: {level}"

IDEs will autocomplete the valid options and warn if you pass an invalid value. mypy will also catch errors:

set_color("red")      # OK
set_color("purple")   # mypy error: Argument has incompatible type "str"

Literal is useful for function parameters that accept a fixed set of string values, like configuration options, modes, or status codes.

TypeAlias: Name Your Types

Create meaningful names for complex types. This makes your code more readable:

from typing import TypeAlias

UserId: TypeAlias = int
Username: TypeAlias = str
UserMap: TypeAlias = dict[UserId, Username]

def get_username(user_id: UserId, users: UserMap) -> Username | None:
    """Get a username by ID."""
    return users.get(user_id)

Instead of seeing dict[int, str] everywhere, you see UserMap — which tells you what the dictionary represents. Type aliases are especially useful for deeply nested types:

# Without alias — hard to read
def process(data: dict[str, list[tuple[int, str]]]) -> None: ...

# With alias — clear
Record: TypeAlias = tuple[int, str]
DataSet: TypeAlias = dict[str, list[Record]]
def process(data: DataSet) -> None: ...

Python 3.12 introduced a cleaner built-in type statement that replaces TypeAlias. It requires no import and is more explicit:

# Python 3.12+ — built-in type alias syntax
type UserId = int
type Matrix = list[list[float]]

The type statement is cleaner than TypeAlias because it is a dedicated keyword that makes the alias visible to type checkers and IDEs without any import. If you are on Python 3.12+, prefer type over TypeAlias.

Annotated: Add Metadata to Types

Annotated adds metadata to a type hint. Python ignores the metadata, but frameworks like Pydantic and FastAPI use it for validation:

from typing import Annotated, Any

def create_user(
    name: Annotated[str, "Must be non-empty"],
    age: Annotated[int, "Must be 0-150"],
) -> dict[str, Any]:
    if not name.strip():
        raise ValueError("Name must be non-empty.")
    if age < 0 or age > 150:
        raise ValueError("Age must be between 0 and 150.")
    return {"name": name.strip(), "age": age}

In FastAPI, Annotated adds validation and documentation automatically:

# FastAPI example
from fastapi import Query

@app.get("/users")
def get_users(
    page: Annotated[int, Query(ge=1)] = 1,
    limit: Annotated[int, Query(ge=1, le=100)] = 10,
):
    ...

The Query(ge=1, le=100) is metadata that FastAPI uses to validate query parameters and generate API documentation. The type itself (int) is separate from the constraints.

Callable: Type-Hint Functions

Use Callable to type functions that take other functions as arguments:

from typing import Callable

def apply_operation(
    values: list[int],
    operation: Callable[[int], int],
) -> list[int]:
    """Apply an operation to each value."""
    return [operation(v) for v in values]

def apply_filter(
    values: list[int],
    predicate: Callable[[int], bool],
) -> list[int]:
    """Filter values using a predicate function."""
    return [v for v in values if predicate(v)]

The syntax is Callable[[argument_types], return_type]:

  • Callable[[int], int] — takes one int, returns an int
  • Callable[[str, int], bool] — takes a string and int, returns a bool
  • Callable[[], None] — takes nothing, returns nothing
  • Callable[..., int] — takes anything, returns an int
doubled = apply_operation([1, 2, 3], lambda x: x * 2)
# [2, 4, 6]

evens = apply_filter([1, 2, 3, 4, 5], lambda x: x % 2 == 0)
# [2, 4]

TYPE_CHECKING: Avoid Circular Imports

Sometimes you need to import a type only for type hints, not at runtime. This happens when two modules import each other. Use TYPE_CHECKING:

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from pathlib import Path  # Only imported during type checking

def format_path(path_str: str) -> str:
    """Format a path string."""
    return path_str.replace("\\", "/")

The TYPE_CHECKING constant is False at runtime and True when type checkers (mypy) analyze the code. This means the import only happens during static analysis.

Combine with from __future__ import annotations to make all annotations lazy (evaluated as strings, not at runtime):

from __future__ import annotations
# Now all type hints are strings — not evaluated at runtime
# This avoids issues with forward references and circular imports

Dataclass with Type Hints

Type hints work naturally with dataclasses and provide excellent IDE support:

from dataclasses import dataclass

@dataclass
class Product:
    name: str
    price: float
    tags: list[str]
    category: Literal["electronics", "clothing", "food"]
    discount: float | None = None

    def final_price(self) -> float:
        if self.discount is not None:
            return self.price * (1 - self.discount)
        return self.price

With these hints, your IDE knows the type of every field and method return value. Autocomplete works perfectly.

mypy: Static Type Checking

mypy is the most popular static type checker for Python. It analyzes your code without running it:

pip install mypy
mypy src/py16_type_hints.py

mypy catches errors like these:

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

result = add("hello", "world")
# mypy error: Argument 1 to "add" has incompatible type "str"; expected "int"
def get_name(user: dict[str, str]) -> str:
    return user["name"]

name: int = get_name({"name": "Alex"})
# mypy error: Incompatible types in assignment (expression has type "str", variable has type "int")

mypy Configuration

Add mypy settings to pyproject.toml:

[tool.mypy]
python_version = "3.13"
strict = true
warn_return_any = true
warn_unused_configs = true

Strict mode enables all checks. For existing projects, start without strict mode and enable checks gradually:

[tool.mypy]
python_version = "3.13"
check_untyped_defs = true
disallow_untyped_defs = false  # Enable later

When to Use Type Hints

SituationRecommendation
Public functions/methodsAlways — other code depends on them
Function parameters and returnsAlways
Class attributesAlways (use dataclass)
Local variablesOnly when not obvious
Small scriptsOptional
Libraries and packagesAlways — users depend on them

Start simple. Add hints to function signatures first. You do not need to annotate every variable — Python and mypy can infer most local variable types:

# No annotation needed — mypy infers 'name' is str
name = greet("Alex")

# Annotation helpful when not obvious
items: list[str] = []
config: dict[str, Any] = load_config()

Common Patterns

# Function that returns nothing
def log(message: str) -> None:
    print(message)

# Function that accepts any type
from typing import Any
def debug(value: Any) -> str:
    return repr(value)

# Function with *args and **kwargs
def my_func(*args: int, **kwargs: str) -> None:
    ...

# Class method returning the class itself
class Builder:
    def set_name(self, name: str) -> "Builder":
        self.name = name
        return self

Gradual Typing: Adding Hints to Existing Code

You do not need to add type hints to your entire codebase at once. The recommended approach is:

  1. Start with new code — add hints to all new functions and classes
  2. Annotate public APIs — functions and methods that other code calls
  3. Run mypy with basic settings — catch the easy errors first
  4. Gradually increase strictness — enable more mypy checks over time
# Start with this (lenient)
[tool.mypy]
python_version = "3.13"
check_untyped_defs = true

# Later, enable these one by one
disallow_untyped_defs = true
warn_return_any = true
strict = true

Many large Python projects took years to add full type coverage. Python’s type system is designed for gradual adoption — you get benefits even with partial coverage.

Type Hints vs Runtime Validation

Type hints are checked statically (by mypy). They do not enforce types at runtime:

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

# This runs fine! Python ignores type hints at runtime.
result = add("hello", "world")  # Returns "helloworld"
# But mypy catches this: error: Argument 1 has incompatible type "str"

If you need runtime validation, use Pydantic (Tutorial #10) or manual checks. Type hints and Pydantic complement each other: hints document the interface, Pydantic enforces it at the boundary.

Source Code

You can find the code for this tutorial on GitHub:

kemalcodes/python-tutorial — tutorial-16-type-hints

Run the examples:

python src/py16_type_hints.py

Run the tests:

python -m pytest tests/test_py16.py -v

What’s Next?

In the next tutorial, we will learn about testing with pytest — how to write tests that catch bugs and make refactoring safe.