Type Hints Fundamentals — Annotating Variables, Functions and Classes

Python type hints are annotations that describe the expected types of variables, function parameters, and return values. They are purely informational at runtime — Python ignores them and does not enforce them — but they are actively used by IDEs (for autocompletion and error highlighting), static type checkers (mypy, pyright), and libraries that read them at runtime (Pydantic, FastAPI). FastAPI is built on the premise that type hints carry enough information to validate request data, generate API documentation, and serialise responses automatically. Adding type hints to your FastAPI code is not optional decoration — it is how FastAPI knows what to do with your data.

Annotating Variables and Functions

# ── Variable annotations ───────────────────────────────────────────────────────
name:      str  = "Alice"
age:       int  = 30
score:     float = 9.5
is_active: bool = True

# Annotation without assignment (declares but does not initialise)
user_id: int   # just the type — no value yet

# ── Function parameter and return type hints ───────────────────────────────────
def greet(name: str, times: int = 1) -> str:
    return (f"Hello, {name}! " * times).strip()

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

def get_user(user_id: int) -> dict:
    return {"id": user_id, "name": "Alice"}

# Function that returns nothing
def log_event(message: str) -> None:
    print(f"[LOG] {message}")

# ── Class attribute annotations ───────────────────────────────────────────────
class Post:
    id:         int
    title:      str
    body:       str
    published:  bool = False
    view_count: int  = 0

    def __init__(self, id: int, title: str, body: str) -> None:
        self.id    = id
        self.title = title
        self.body  = body

    def publish(self) -> None:
        self.published = True

    def word_count(self) -> int:
        return len(self.body.split())
Note: Python type hints are not enforced at runtime. Calling greet(123, "not-an-int") will not raise a TypeError — Python will try to run the code with those values. The hints are for developer tools. The exceptions are Pydantic models and FastAPI route handlers — these actively read type hints at runtime and enforce them. This distinction is why you can have a type hint of int on a parameter but still pass a string in plain Python, while the same type hint on a FastAPI route parameter causes an automatic 422 validation error.
Tip: Use -> None explicitly on functions that do not return a value — it makes intent clear and helps mypy catch bugs where you accidentally return a value from a function that should not. Use -> None on __init__ methods (constructors always return None). Leave the return type unannotated only when the return type is genuinely complex to express — prefer explicit annotations everywhere.
Warning: Do not use a type hint as a substitute for documentation. def process(data: dict) -> list: says the parameter is a dict and returns a list, but says nothing about what keys the dict must have or what items the list contains. Use more specific types: dict[str, int], list[Post], or better yet, Pydantic models that document the full structure and validate it. In FastAPI, imprecise type hints produce imprecise API documentation.

Python 3.10+ Modern Syntax

# Python 3.10+ allows | for unions and built-in generics without importing typing
# This is the modern style — use it for Python 3.10+ projects

# Old style (all Python 3.x)
from typing import Optional, Union, List, Dict, Tuple

def old_style(
    name: Optional[str],              # Optional[X] = Union[X, None]
    items: List[str],
    scores: Dict[str, int],
    pair: Tuple[int, str],
    value: Union[int, float],
) -> Optional[str]:
    ...

# New style (Python 3.10+) — no typing import needed for basic cases
def new_style(
    name: str | None,                 # | None replaces Optional
    items: list[str],                 # built-in list works directly
    scores: dict[str, int],           # built-in dict works directly
    pair: tuple[int, str],            # built-in tuple works directly
    value: int | float,               # | replaces Union
) -> str | None:
    ...

# Python 3.9+ — built-in list, dict, tuple work in hints (no import needed)
# Python 3.10+ — | syntax for unions works (no Union import needed)

# For type aliases
PostId = int
UserName = str

def get_post(post_id: PostId) -> dict:
    ...

Type Hints at Runtime

import inspect

# Access type hints at runtime
def process(name: str, count: int) -> bool:
    return len(name) > count

# Python stores hints as __annotations__
print(process.__annotations__)
# {"name": <class "str">, "count": <class "int">, "return": <class "bool">}

# typing.get_type_hints() resolves forward references
import typing
hints = typing.get_type_hints(process)
# {"name": str, "count": int, "return": bool}

# FastAPI reads these annotations to know what types to expect
# Pydantic reads them to build validation schemas
# mypy reads them for static checking (never at runtime)

Common Mistakes

Mistake 1 — Thinking type hints prevent wrong types at runtime

❌ Wrong — assuming hints enforce types:

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

result = add("hello", " world")   # no error! Returns "hello world" (str + str)
print(result)   # "hello world" — type hints were ignored by Python

✅ Correct — use Pydantic or explicit validation if enforcement is needed.

Mistake 2 — Using string annotations when the class is not yet defined

❌ Wrong — NameError if class is used before it is defined:

class Node:
    def add_child(self, child: Node):   # NameError — Node not defined yet!
        ...

✅ Correct — use a string (forward reference) or from __future__ import annotations:

from __future__ import annotations   # makes ALL annotations strings lazily

class Node:
    def add_child(self, child: Node):   # ✓ works with the future import
        ...
# Or without the import: def add_child(self, child: "Node"): ...

Mistake 3 — Imprecise dict/list types in FastAPI models

❌ Wrong — opaque types produce poor API docs:

class PostCreate(BaseModel):
    metadata: dict   # no idea what keys/values are expected

✅ Correct — specific types or nested Pydantic models:

class PostCreate(BaseModel):
    metadata: dict[str, str] = {}   # ✓ string keys and values

Quick Reference

Type Modern syntax (3.10+) Old syntax
Optional string str | None Optional[str]
String or int str | int Union[str, int]
List of strings list[str] List[str]
Dict str→int dict[str, int] Dict[str, int]
Tuple (int, str) tuple[int, str] Tuple[int, str]
No return value -> None -> None
Any type Any (from typing) Any
Forward reference "ClassName" or future import same

🧠 Test Yourself

You annotate a FastAPI route parameter as user_id: int. A client sends the request with user_id=abc in the URL path. What happens?