DEV Community

Cover image for Supercharge Your Python Types with Annotated
Arjun Adhikari
Arjun Adhikari

Posted on

Supercharge Your Python Types with Annotated

Imagine you're labeling boxes. You might label a box "Kitchen Utensils." That's a basic type hint. But what if you also want to add a sticky note saying, "Fragile" or "Open First"? That’s where typing.Annotated comes in. It lets you add extra information, or metadata, to your type hints without changing the fundamental type itself.

Why Bother with Extra Info?

Standard type hints (like user_id: int) are great for telling Python "this should be an integer." But often, you have more to say:

  • "This integer must be positive."
  • "This string should be a valid email address."
  • "This function parameter is coming from a web request's query string."

Annotated provides a neat, built-in way to attach these extra details right where you define the type.

Let's See It in Action: The Basics

First, you'll need to be on Python 3.9 or newer (or use from typing_extensions import Annotated for older versions).

The syntax looks like this: Annotated[TheActualType, Metadata1, Metadata2, ...].

from typing import Annotated, get_type_hints # A simple variable with an annotation user_pin_code: Annotated[int, "Must be a 4-digit positive number"] = 1234 # A function parameter with an annotation def process_order(order_id: Annotated[str, "Internal order tracking ID, UUID format preferred"]): print(f"Processing order with ID: {order_id}") # ... actual processing logic ...  process_order("abc-123-def-456") # How tools *could* see this metadata (you usually don't do this manually) class Customer: email: Annotated[str, "Primary contact email, must be validated"] loyalty_points: Annotated[int, "Cannot be negative"] # Let's peek at the annotations for the Customer class customer_hints = get_type_hints(Customer, include_extras=True) for field_name, hint in customer_hints.items(): print(f"\nField: {field_name}") print(f" Full Hint: {hint}") # The 'include_extras=True' gives us access to __metadata__  if hasattr(hint, '__metadata__'): print(f" Extra Info (Metadata): {hint.__metadata__}") 
Enter fullscreen mode Exit fullscreen mode

In this snippet, Annotated[int, "Must be a 4-digit positive number"] tells us user_pin_code is an integer, but also carries the string "Must be a 4-digit positive number" as extra info. For Python itself and standard type checkers (like MyPy), user_pin_code is still just an int. The magic happens when other libraries or tools are designed to read and use this metadata.

Making Annotated Shine: With Libraries like Pydantic and FastAPI

This is where Annotated truly comes alive. Libraries like Pydantic (for data validation) and FastAPI (for building web APIs) use it extensively.

Example 1: Pydantic for Smarter Data Classes

Pydantic lets you define data structures with validation. Annotated makes this super clean.

from typing import Annotated from pydantic import BaseModel, Field, ValidationError class ProductReview(BaseModel): rating: Annotated[int, Field(gt=0, le=5, description="Star rating from 1 to 5")] comment: Annotated[str | None, Field(default=None, max_length=500, description="Optional user comment")] reviewer_id: Annotated[str, Field(description="Unique ID of the reviewer")] # Let's try creating a review try: good_review = ProductReview(rating=5, reviewer_id="user123", comment="Loved it!") print(f"\nValid Review: {good_review.model_dump_json(indent=2)}") # This one will fail validation  bad_review = ProductReview(rating=7, reviewer_id="user456") # rating > 5  print(bad_review) except ValidationError as e: print(f"\nOops, something's wrong with the review data:\n{e}") 
Enter fullscreen mode Exit fullscreen mode

Here, Field(gt=0, le=5) isn't changing rating from an int. It's metadata Pydantic uses to say "this integer must be greater than 0 and less than or equal to 5." This is much clearer than some older ways of doing things!

Example 2: FastAPI for Robust Web APIs

FastAPI uses Annotated to define details about your API parameters – whether they come from the path, query string, request body, etc., along with validation.

from typing import Annotated from fastapi import FastAPI, Query, Path # Body, Depends could also be used # (Assuming Pydantic BaseModel from previous example if we need request bodies)  app = FastAPI() @app.get("/items/{item_id}") async def read_item( item_id: Annotated[int, Path(title="The ID of the item to get", ge=1)], short_description: Annotated[str | None, Query(default=None, max_length=50)] = None, ): """ Fetches an item by its ID. Optionally, a short_description can be queried. """ item_data = {"item_id": item_id, "name": f"Sample Item {item_id}"} if short_description: item_data["description_snippet"] = short_description return item_data # To run this: save as myapi.py, then run: uvicorn myapi:app --reload # Then open your browser to http://127.0.0.1:8000/items/42?short_description=cool # Or check out the auto-docs at http://127.0.0.1:8000/docs 
Enter fullscreen mode Exit fullscreen mode

Here, Annotated[int, Path(...)] tells FastAPI that item_id is part of the URL path, is an integer, and must be greater than or equal to 1. Annotated[str | None, Query(...)] says short_description is an optional string from the query parameters with a max length.

So, Annotated is like giving your type hints superpowers, making your intentions clearer and enabling powerful library integrations.

LangGraph: Managing Information Flow Like a Champ (The "Reducer" Way)

Now, let's switch gears to LangGraph. It's a library for building complex applications, often involving multiple steps and AI models (think "agents"). One of its clever aspects is how it manages the information, or "state," that flows through your application.

The Challenge: Keeping Everyone on the Same Page

In a multi-step process, each step might produce some information or need information from previous steps. How do you keep track of all this evolving data in an organized way? LangGraph uses a central "state object."

LangGraph's State: Your Central Hub

Think of the LangGraph state as a shared whiteboard. Every step (or "node") in your process can read from this whiteboard and then propose an update to it.

What's a "Reducer" Got to Do With It?

If you've ever worked with state management in web development (like Redux) or some functional programming patterns, the term "reducer" might ring a bell. A reducer is essentially a function that takes the current state and some new information, and produces a new state.

It's like a recipe:

  • Current state = What's already in your mixing bowl.
  • New information = The next ingredient you're adding.
  • Reducer = The instruction for how to combine them (e.g., "stir in," "fold gently," "add to list").
  • New state = The updated contents of your mixing bowl.

LangGraph uses this idea to manage its state updates.

How Nodes Update the State in LangGraph

  1. A node (a function in your graph) gets the current state.
  2. It does its job (calls an AI, runs a tool, etc.).
  3. It then returns a dictionary indicating which parts of the state it wants to update and with what new values. It doesn't change the state directly.

The "Reducer" Magic in LangGraph

When you define your state in LangGraph (often as a special kind of Python dictionary called a TypedDict), you can specify how updates to each piece of information (each key in your state dictionary) should be handled.

  • Default: Just Replace It: If you don't specify anything special, a new value from a node will simply overwrite the old value for that key in the state.
  • Be Specific: Use a Reducer Function! For more complex updates, you define a reducer. For example, if a key in your state holds a list of messages (like in a chatbot), you don't want a new message to replace the whole list. You want to add it to the existing list.

Example: Building Up a List of Actions (Reducer Style)

Let's say our LangGraph state needs to keep a list of actions an agent has taken.

from typing import TypedDict, Annotated, List import operator # We'll use operator.add for list concatenation  # 1. Define our State with a reducer for 'action_history' class AgentWorkflowState(TypedDict): current_task: str # When a node returns an update for 'action_history',  # the new list items will be appended to the existing list.  action_history: Annotated[List[str], operator.add] final_summary: str | None # Default: an update will overwrite  # (Imagine this is part of a LangGraph StateGraph setup) # graph_builder = StateGraph(AgentWorkflowState) # ... add nodes and edges ...  # 2. A node that performs an action and updates the history def perform_research_node(state: AgentWorkflowState) -> dict: task = state['current_task'] print(f"Node: Performing research for task: {task}") # Simulate research and identify actions taken  action1 = f"Looked up '{task}' on web." action2 = f"Summarized top 3 results for '{task}'." # This node returns *only the changes* it wants to make to the state.  return { "action_history": [action1, action2], # These will be appended  "current_task": f"Next step for {task}" # This will overwrite  } # 3. Another node might add more to the history def generate_report_node(state: AgentWorkflowState) -> dict: actions_so_far = state['action_history'] print(f"Node: Generating report based on {len(actions_so_far)} actions.") report_action = "Compiled all findings into a draft report." summary = f"Report generated based on task: {state['current_task']} and actions: {'; '.join(actions_so_far + [report_action])}" return { "action_history": [report_action], # This will also be appended  "final_summary": summary # This will overwrite  } # --- How LangGraph might use this (simplified) --- # Initial state current_state_values = { "current_task": "Impact of AI on climate change", "action_history": ["Task initiated"], # Start with an initial history item  "final_summary": None } print(f"Initial State: {current_state_values}") # Simulate running the first node updates_from_research = perform_research_node(current_state_values) # LangGraph applies updates using reducers # For 'action_history', it uses operator.add (current_history + new_items) current_state_values["action_history"] = operator.add(current_state_values["action_history"], updates_from_research.get("action_history", [])) current_state_values["current_task"] = updates_from_research.get("current_task", current_state_values["current_task"]) print(f"\nState after research: {current_state_values}") # Simulate running the second node updates_from_report = generate_report_node(current_state_values) current_state_values["action_history"] = operator.add(current_state_values["action_history"], updates_from_report.get("action_history", [])) current_state_values["final_summary"] = updates_from_report.get("final_summary", current_state_values["final_summary"]) print(f"\nState after report: {current_state_values}") 
Enter fullscreen mode Exit fullscreen mode

In this LangGraph scenario:

  • We defined action_history: Annotated[List[str], operator.add]. The operator.add (which works for concatenating lists) is the reducer function.
  • When perform_research_node returns {"action_history": [action1, action2]}, LangGraph doesn't just replace the old action_history. It takes the current action_history, takes the new list [action1, action2], and uses operator.add to combine them.
  • This is super handy because if multiple parts of your graph contribute to the same list or need to modify a value in a cumulative way, reducers handle this merge logic cleanly and predictably. LangGraph even has a built-in add_messages reducer specifically for managing lists of chat messages, which is a common use case.

So, when you see state updates in LangGraph, especially with Annotated in the state definition, think "reducer"! It's how LangGraph ensures that information from different parts of your application comes together in a controlled and predictable way.

Wrapping Up

Both typing.Annotated and LangGraph's reducer-like state management are about making your Python code more expressive, robust, and easier to reason about.

  • Annotated lets you embed rich, contextual information directly into your type hints, which libraries can then use for things like validation, serialization, or API documentation.
  • LangGraph’s approach to state updates, using node outputs and defined reducers for state keys, ensures that complex flows of information are handled gracefully and predictably.

By understanding these patterns, you can build more sophisticated and maintainable Python applications. Give them a try in your next project!

Top comments (0)