Adding Python Type Hints With LLMs in October 2026: Show Real Call Sites, Push Back on Any, and Let mypy Have the Last Word
Adding type hints to an existing Python codebase is slow, repetitive work, and that makes it look like a perfect job for an LLM. Paste in a module, ask for annotations, and get back a file that looks fully typed. The catch is that a type hint is a claim about how your code behaves. If the model guesses wrong, you end up with annotations that are confidently incorrect. A type checker then either complains about code that works, or, worse, stays quiet about code that doesn't.
This guide covers a practical way to use LLMs for Python type hints: give the model real evidence about how functions are called, push back on lazy annotations like Any, and let a type checker such as mypy or Pyright decide what is correct. The model drafts and the checker judges.
Why type hints are harder than they look
A model reading a function in isolation sees only the function body. It doesn't see the callers, the data that actually flows through, or the edge cases your team has hit in production. So it fills the gaps with plausible guesses. Common ones include:
- Annotating a parameter as
strwhen callers sometimes passNone. - Returning
dictwhen the function really returns a dictionary with a fixed set of keys that deserves aTypedDictor a dataclass. - Using
list[str]for a parameter that only gets iterated, whereIterable[str]would accept tuples, sets, and generators too. - Falling back to
Anywhenever something is unclear, which turns type checking off for that value without telling anyone. - Writing syntax your Python version doesn't support, such as
X | Noneon a project that still runs Python 3.8.
None of these mistakes crash anything right away, because Python ignores annotations at runtime. That's exactly why they're risky: wrong hints can sit in a codebase for months and quietly mislead every developer and tool that reads them.
Step 1: Tell the model your Python version and checker settings
Start every request with the facts that decide which syntax is valid. Include your minimum Python version, which type checker you use, and how strict it is. For example:
Project facts:
- Minimum Python version: 3.10
- Type checker: mypy, run with --strict
- Use built-in generics (list[str], dict[str, int]) and X | None syntax.
- Do not use Any unless you explain why in a comment.
This removes a whole class of errors. On Python 3.9 and later you can write list[str] instead of importing List from typing, and on 3.10 and later you can write int | None instead of Optional[int]. If you support older versions, say so, and the model can use from __future__ import annotations or the older typing forms where they're needed.

Type hints are claims about real code: show the model the real code and its callers. Image: Python code example.png by Zhanjack822, CC0, via Wikimedia Commons.
Step 2: Show real call sites, not just the function
The best evidence for a parameter's type is how the code actually calls it. Before you ask for annotations, collect a few real call sites. A quick search gets you most of the way:
grep -rn "load_user(" src/ tests/
Then paste the function along with those calls. A prompt might look like this:
Add type hints to load_user below. Here are three real call sites.
If the call sites disagree about a type, point that out instead
of picking one silently.
def load_user(user_id, cache=None):
...
# call sites
load_user(42)
load_user(request.args.get("id"), cache=redis_cache)
load_user(int(row["id"]), cache=None)
Here the second call passes request.args.get("id"), which in Flask returns a string or None, while the other calls pass integers. That's a real inconsistency in the code, and asking the model to flag disagreements surfaces it. The right fix might be to convert the value before calling, not to widen the annotation to int | str | None. Deciding that is your job, but the model can point you to the spot.
Tests are another good source of evidence. If a test passes a tuple where the code seems to expect a list, include that test in the prompt.
Step 3: Ask for precise types, and push back on Any
Models reach for Any, plain dict, and plain object when they're unsure. Make it clear you'd rather have a precise type or an explicit question. Some patterns worth asking for by name:
Use abstract types for inputs
If a function only reads from a sequence, annotate it with Iterable or Sequence from collections.abc rather than list. If it only reads from a mapping, use Mapping rather than dict. Your callers get more flexibility, and the hint documents that the function doesn't modify its input.
Use concrete types for outputs
Return types should usually be specific. If a function builds and returns a list, say list[User], so callers know they can append to it.
Name the shape of structured dictionaries
A function that returns {"id": ..., "email": ..., "active": ...} every time deserves a TypedDict or a dataclass rather than dict[str, Any]:
from typing import TypedDict
class UserRecord(TypedDict):
id: int
email: str
active: bool
def fetch_user_record(user_id: int) -> UserRecord:
...
Use overloads when the return type depends on an argument
If a function returns a string when raw=False and bytes when raw=True, a single annotation like str | bytes forces every caller to check the type. typing.overload lets the checker pick the right return type from the argument. Ask the model to suggest overloads when it spots this pattern, but review them carefully, since overloads are easy to get subtly wrong.
A type checker works like a shape sorter: it shows you what fits, so let it judge every draft. Image: Shape sorter.jpg by Mvolz, CC0, via Wikimedia Commons.
Step 4: Run the type checker on every draft
Never merge model-written annotations without running a checker over them. With mypy:
pip install mypy
mypy src/
Or with Pyright:
pip install pyright
pyright src/
When the checker reports errors, paste the exact error output back to the model along with the relevant code. Don't paraphrase it. Messages like Argument 1 to "load_user" has incompatible type "str | None"; expected "int" are precise, and they often point to a real bug rather than a bad annotation.
Watch for one tempting shortcut. Models sometimes "fix" checker errors by adding cast() calls or # type: ignore comments. Both silence the checker without proving anything. Treat each one as a question: is the code actually safe here, or did we just hide a bug? If an ignore really is justified, require an error code and a reason, such as # type: ignore[attr-defined] # attribute added by plugin at runtime.
Step 5: Add types gradually, starting at the edges
Typing a whole codebase in one pass produces a huge diff nobody can review. A gradual approach works better:
- Start with leaf modules. Utility functions with few dependencies are easy to type correctly and give the checker solid ground to stand on.
- Then type public interfaces. Functions that other modules call matter most, because their hints spread to every caller.
- Turn on strictness per module. In mypy you can keep global settings relaxed and tighten them for finished modules:
# mypy.ini
[mypy]
python_version = 3.10
warn_unused_ignores = True
[mypy-myapp.utils.*]
disallow_untyped_defs = True
disallow_any_generics = True
The warn_unused_ignores setting is especially useful when models are involved, because it flags leftover # type: ignore comments that no longer suppress anything.
Keep each pull request to a module or two. A reviewer can check twenty annotated functions properly but will skim two hundred.
Step 6: Check that runtime behavior didn't change
Annotations themselves don't change behavior, but model edits sometimes do. While adding hints, a model may "tidy up" a default argument, reorder parameters, or change if not value to if value is None. That last change looks harmless, but it treats empty strings and zero differently than before.
Two habits catch this:
- Ask for annotations only. Tell the model explicitly: change annotations and imports, nothing else. If it thinks a logic change is needed, it should say so in a separate note.
- Read the diff with whitespace ignored and run your test suite.
git diff -wmakes it easier to spot any line that changed more than its signature.
Step 7: Use runtime tools to settle disputes
When you genuinely don't know what a function receives, don't let the model guess. Find out. A few options:
- Add a temporary
reveal_type(x)call. mypy and Pyright print the inferred type when they check the file. Remove it afterward, since it isn't meant for production code. - Log
type(value)in a test or staging run to see what actually flows through. - Use a tool like MonkeyType, which records the argument and return types it sees while your tests run and can generate stub annotations from them. Treat its output as evidence, not truth, since it only knows about the cases your tests exercise.
Real observed types plus the model's reading of the code make a much stronger case than either one alone.
A prompt template you can reuse
You are adding Python type hints. Rules:
- Python 3.10+, checker is mypy --strict.
- Change only annotations and imports. Do not change logic.
- Prefer Iterable/Sequence/Mapping for inputs, concrete types for outputs.
- Use TypedDict or dataclasses for fixed-shape dicts.
- Do not use Any, cast(), or "# type: ignore" without a comment explaining why.
- If call sites or tests disagree about a type, list the disagreement
instead of picking one.
Code:
<paste module>
Call sites and tests:
<paste relevant snippets>
The bottom line
LLMs are good at the tedious part of typing Python: writing out signatures, importing the right names, and spotting where a TypedDict would help. They're unreliable at knowing what your code actually receives at runtime. Give them real call sites and tests, insist on precise types instead of Any, keep edits limited to annotations, and run mypy or Pyright on every draft. Treat each # type: ignore the model adds as a question to answer, not a fix. Done that way, the model saves you hours of typing and the checker makes sure the result is true.
Comments
Post a Comment