Working With Data

Type Hints

Type Hints

Python does not require you to declare types. Type hints let you write them down anyway, as notes for your editor, your tools and the next person to read the file.

In agent code they are not optional in practice, because frameworks read them. The type hints on a tool function are what become the schema the model sees. Leave them off and the tool does not work properly.

The basic form

def add(a: int, b: int) -> int:
    return a + b
  • a: int — this parameter should be a whole number
  • -> int — this function gives back a whole number

Variables can be annotated too, though you need it far less often:

name: str = "Priya"
retries: int = 0

Python does not enforce them

This is the part that surprises people from Java and C#.

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

print(add("hello", "world"))

That prints helloworld. No error. The hint is a note, not a rule — the interpreter ignores it entirely.

So what is the point?

  • Your editor uses them for autocomplete and warns you before you run
  • A type checker such as mypy or Pyright catches mistakes across the whole project
  • Frameworks read them at runtime to build schemas
  • A reader learns what a function expects without running it

The first and last are worth it on their own. The third is why this page sits in this tutorial.

The types you will actually use

from typing import Optional, Any

def f(
    text: str,
    count: int,
    ratio: float,
    enabled: bool,
    items: list[str],
    config: dict[str, int],
    pair: tuple[int, str],
    maybe: Optional[str] = None,
    anything: Any = None,
) -> None:
    ...
HintMeaning
str, int, float, boolthe basic values
list[str]a list of strings
dict[str, int]keys are strings, values are whole numbers
tuple[int, str]exactly two items, in that order
Optional[str]a string, or None
Anyanything at all; switches checking off
-> Nonereturns nothing useful

list[str] with lowercase list works on Python 3.9 and later. Older code imports List from typing and writes List[str]. Both mean the same; prefer the lowercase form.

Optional means "or None"

def find_owner(ticket_id: int) -> Optional[str]:
    ...

This says: you get back a string, or you get None. The caller has been told to check.

Optional[str] is the same as str | None, and the newer | form reads better:

def find_owner(ticket_id: int) -> str | None:
    ...

Note carefully: on a parameter, Optional does not make the argument optional. It makes the type allow None. What makes it optional is a default value.

def f(a: Optional[str]):        # required, may be None
def f(a: Optional[str] = None): # optional

Why frameworks depend on them

Here is the payoff.

from langchain_core.tools import tool

@tool
def get_weather(city: str, unit: str = "celsius") -> str:
    """Get the current weather for a city."""
    return f"28 degrees {unit} in {city}"

The @tool decorator inspects this function and builds a JSON schema from it:

  • the parameter names become the schema's properties
  • the type hints become the property types
  • the absence of a default makes city required
  • the docstring becomes the description the model reads to decide when to call it

Remove the type hints and the framework cannot say what city is. Remove the docstring and the model does not know what the tool is for. Both failures are quiet — the code runs, the agent simply behaves badly.

So in agent code the rule is firm: every tool function gets full type hints and a real docstring.

Callables

When a function takes another function, you can describe its shape:

from typing import Callable

def apply(func: Callable[[int], str], value: int) -> str:
    return func(value)

Callable[[int], str] means "takes one int, gives back a str". You will see this in framework signatures for nodes, routers and callbacks. Reading it is enough; you rarely write it.

Type aliases

When a shape repeats, name it.

Message = dict[str, str]

def send(history: list[Message]) -> Message:
    ...

Easier to read than list[dict[str, str]] in five places, and you can change it in one spot.

Checking your types

Hints do nothing until something checks them. In an editor, install Pylance or Pyright and you get warnings as you type. On the command line:

pip install mypy
mypy myagent/

You do not need this on day one. It becomes worthwhile the moment more than one person touches the code.

How much to annotate

A reasonable standard, and the one this tutorial follows:

  • always on tool functions — frameworks depend on it
  • always on anything crossing a boundary — public functions, module entry points
  • usually on function parameters and return values
  • rarely on local variables, where it is obvious and just noise
count = 0
names: list[str] = []

The first needs no hint. The second is worth one, because an empty list gives your editor nothing to work from.

Coming from Java or C#

The syntax will feel backwards, and the guarantees are much weaker.

Java / C#Python
Where the type goesbefore the name, int xafter the name, x: int
Return typebefore the method nameafter the parameters, -> int
Enforced?yes, by the compilerno, by nothing at runtime
When mistakes surfaceat compile timewhen your editor or mypy looks
OptionalOptional<T>, nullable T?Optional[T], `T \None`
GenericsList<String>list[str]

The hard adjustment: nothing stops a wrong type at runtime. A hint is a claim, not a guarantee. If you need a real guarantee — and for anything a model produced, you do — use a Pydantic model, which actually validates.

That is the division worth remembering: type hints for your tools and your editor, Pydantic for data you cannot trust.

Common mistakes

  • Leaving hints off a tool function, so the schema is incomplete and the model misuses it.
  • Assuming they are enforced. They are not.
  • Optional[str] with no = None, expecting the argument to be optional.
  • Any everywhere, which switches off the benefit you added them for.
  • Annotating every local variable, which adds noise without adding information.

Practise this

  • Add hints to a function you have already written and see what your editor starts telling you.
  • Write a function returning str | None and handle both outcomes at the call site.
  • Write a tool-shaped function with full hints and a proper docstring, then say out loud what a framework could build from it.
  • Deliberately pass the wrong type and confirm Python runs it anyway.

Try It Yourself

Copy this into a new Colab notebook and run it. Nothing to install.

example.py
1# Type hints are notes for your editor and for frameworks.
2# Python itself does NOT enforce them.
3
4from typing import Any, Callable, Optional
5
6
7def add(a: int, b: int) -> int:
8 return a + b
9
10
11print(add(2, 3))
12
13# Nothing stops this. No error, no warning at runtime.
14print("unenforced:", add("hello", "world"))
15print()
16
17# --- The hints you will actually use ---
18def describe(
19 text: str,
20 count: int,
21 ratio: float,
22 enabled: bool,
23 items: list[str],
24 config: dict[str, int],
25 pair: tuple[int, str],
26 maybe: Optional[str] = None, # str or None
27 anything: Any = None,
28) -> None:
29 print(text, count, ratio, enabled, items, config, pair, maybe, anything)
30
31
32describe("hi", 1, 0.5, True, ["a"], {"x": 1}, (1, "a"))
33print()
34
35# --- Optional does not make a PARAMETER optional. The default does. ---
36def required_but_nullable(a: Optional[str]): # must be passed, may be None
37 return a
38
39def truly_optional(a: Optional[str] = None): # may be omitted
40 return a
41
42print(required_but_nullable(None), truly_optional())
43print()
44
45# --- The modern form: str | None ---
46def find_owner(ticket_id: int) -> str | None:
47 owners = {4471: "network"}
48 return owners.get(ticket_id)
49
50for tid in (4471, 9999):
51 owner = find_owner(tid)
52 print(tid, "->", owner if owner is not None else "unassigned")
53print()
54
55# --- Callable: a function that takes a function ---
56def apply(func: Callable[[int], str], value: int) -> str:
57 return func(value)
58
59print(apply(lambda n: f"value is {n}", 42))
60print()
61
62# --- Type aliases, when a shape repeats ---
63Message = dict[str, str]
64
65def send(history: list[Message]) -> Message:
66 return {"role": "assistant", "content": f"seen {len(history)} messages"}
67
68print(send([{"role": "user", "content": "hi"}]))
69print()
70
71# --- WHY THIS PAGE MATTERS: frameworks read the hints ---
72def get_weather(city: str, unit: str = "celsius") -> str:
73 """Get the current weather for a city."""
74 return f"28 degrees {unit} in {city}"
75
76print("annotations:", get_weather.__annotations__)
77print("docstring :", get_weather.__doc__)
78print()
79print("From those two lines alone, a framework can build:")
80print("""{
81 "name": "get_weather",
82 "description": "Get the current weather for a city.",
83 "parameters": {
84 "type": "object",
85 "properties": {
86 "city": {"type": "string"},
87 "unit": {"type": "string", "default": "celsius"}
88 },
89 "required": ["city"]
90 }
91}""")
92print("Remove the hints and it cannot say what `city` is.")
93print("Remove the docstring and the model does not know when to use it.")