Type Hinting & Static Analysis
Python's dynamic typing is both its greatest feature and its most dangerous quality. In small scripts, skipping type annotations costs you nothing. In a production AI codebase with 20+ modules, passing the wrong type through five function calls produces a confusing runtime error deep inside an SDK — far from where the mistake was made.
Type hints were introduced in Python 3.5 and have evolved rapidly. Combined with a static type checker like mypy or Pyright, they give you a development experience approaching C#'s compile-time safety — without changing Python's runtime behavior.
The Basic Type Hint Syntax
// Types enforced at compile time
string Summarize(string text, int maxTokens) { ... }
List<string> ExtractKeywords(string text) { ... }
Dictionary<string, float> ScoreResponses(
List<string> responses) { ... }
# Types are hints — ignored at runtime
def summarize(text: str, max_tokens: int) -> str: ...
def extract_keywords(text: str) -> list[str]: ...
def score_responses(
responses: list[str]
) -> dict[str, float]: ...
Built-in Type Hints (Python 3.9+)
Coming from C#, the biggest surprise is that Python's type hints are optional and not enforced at runtime — they exist for your IDE and static checkers, not the interpreter. Modern Python (3.9+) lets you annotate with the built-in collection types directly (list[str], dict[str, float]) and express unions with | (3.10+), so you rarely need the older typing imports. The two versions below are equivalent.
# Python 3.9+ — use built-in generics directly (no import needed)
def process_chunks(
chunks: list[str],
scores: dict[str, float],
ids: tuple[str, ...],
unique_models: set[str],
) -> list[dict[str, str | float]]: # union with | (3.10+)
...
# Python 3.8 and earlier — import from typing
from typing import List, Dict, Tuple, Set, Union, Optional
def process_chunks_old(
chunks: List[str], # same as list[str] in 3.9+
scores: Dict[str, float],
result: Optional[str] = None, # same as str | None
) -> List[Dict[str, Union[str, float]]]: # same as list[dict[str, str | float]]
...
Optional and None
Python's str | None (or the older Optional[str]) is the direct analog of C#'s nullable reference types — it tells the checker a value may be absent. Unlike C#, nothing stops you from ignoring it at runtime, so the annotation's value is entirely in the warnings your IDE and mypy raise when you forget to handle the None case.
from typing import Optional
# These are equivalent in Python 3.10+
def find_model(name: str) -> str | None: ...
def find_model_old(name: str) -> Optional[str]: ... # same thing
# Optional parameter with a default
def call_llm(
prompt: str,
model: str = "gpt-4o",
system: str | None = None, # None means "use default"
) -> str:
system_prompt = system or "You are a helpful assistant."
...
Complex AI-Specific Type Patterns
TypedDict: Typed Dictionaries
When you're stuck with dict-based APIs (like the OpenAI SDK's messages parameter), TypedDict adds type safety without switching to dataclasses:
from typing import TypedDict, Literal
class Message(TypedDict):
role: Literal["user", "assistant", "system"] # only these string values
content: str
class ContentBlock(TypedDict):
type: Literal["text"]
text: str
# Now this is type-checked:
messages: list[Message] = [
{"role": "user", "content": "What is RAG?"},
]
# mypy/Pyright will catch this:
bad_messages: list[Message] = [
{"role": "admin", "content": "..."}, # ERROR: "admin" not in Literal
]
Callable Types
Callable[[ArgTypes], ReturnType] types a function value, exactly like C#'s Func<string, string>. This matters in AI pipelines where you pass transform and embedding functions around as arguments — a named type alias like EmbedFn documents the expected signature and lets the checker catch a mismatched callback.
from typing import Callable
# Function that takes a string and returns a string
TransformFn = Callable[[str], str]
# More complex callable
EmbedFn = Callable[[list[str]], list[list[float]]]
def apply_transform(
texts: list[str],
transform: TransformFn,
) -> list[str]:
return [transform(t) for t in texts]
# Type alias for clarity
LLMResponse = dict[str, str | int | list]
Generic Types
Generics work the same way they do in C#: a TypeVar is your T, and Generic[T] makes a class parameterizable so one container can hold any output type while preserving type information. The PipelineResult[T] below lets a pipeline stage return a strongly-typed result whether the payload is a string, a list of chunks, or an embedding.
from typing import TypeVar, Generic
T = TypeVar("T")
class PipelineResult(Generic[T]):
"""A result container that works with any output type."""
def __init__(self, value: T, metadata: dict) -> None:
self.value = value
self.metadata = metadata
def map(self, fn: Callable[[T], T]) -> "PipelineResult[T]":
return PipelineResult(fn(self.value), self.metadata)
# Usage with specific types
text_result: PipelineResult[str] = PipelineResult("hello", {"tokens": 5})
list_result: PipelineResult[list[str]] = PipelineResult(["a", "b"], {})
Setting Up mypy
mypy is the reference static type checker for Python. Pyright (by Microsoft, used by Pylance in VS Code) is faster and stricter — use Pyright in your editor and mypy in CI.
pip install mypy --break-system-packages
# Run on your src directory
mypy src/
# Or check a single file
mypy src/client.py
Configure mypy once in pyproject.toml — the Python equivalent of tuning nullable and analysis settings in your .csproj or .editorconfig. A pragmatic approach is to start permissive across the codebase and enforce strict = true on your core modules first, tightening outward over time.
# pyproject.toml — mypy configuration
[tool.mypy]
python_version = "3.11"
strict = false # start permissive, tighten over time
warn_return_any = true
warn_unused_ignores = true
ignore_missing_imports = true # for third-party stubs not yet available
# Per-module overrides
[[tool.mypy.overrides]]
module = "src.core.*"
strict = true # enforce strict typing in core modules
mypy Strictness Levels
| Flag | What it Checks |
|---|---|
--strict
|
Everything below, plus disallows untyped defs |
--warn-return-any
|
Functions returning Any implicitly |
--disallow-any-generics
|
list without type param (list[str] required)
|
--no-implicit-optional
|
Params with = None must be T | None
|
--warn-unreachable
|
Code after return or impossible branches |
Type Annotations in Practice: AI Codebase
Here's what the pieces look like assembled into a realistic, fully typed pipeline module. Nothing new is introduced — it just shows how built-in generics, Optional, type aliases, and return types combine to make an AI codebase self-documenting and safe to refactor, the way strong typing does across a .NET solution.
# src/pipeline.py — fully typed AI pipeline component
from __future__ import annotations
from typing import Protocol, runtime_checkable
from openai import OpenAI
@runtime_checkable
class LLMClient(Protocol):
"""Structural protocol — any object with these methods satisfies it."""
def complete(self, prompt: str, **kwargs: str | int | float) -> str: ...
def usage_report(self) -> dict[str, int | float | str]: ...
def build_rag_context(
chunks: list[str],
query: str,
max_context_tokens: int = 4000,
) -> str:
"""Build a RAG context string from retrieved chunks."""
context_parts: list[str] = []
total_chars = 0
# Rough estimate: 1 token ≈ 4 chars
char_limit = max_context_tokens * 4
for i, chunk in enumerate(chunks):
if total_chars + len(chunk) > char_limit:
break
context_parts.append(f"[Source {i+1}]\n{chunk}")
total_chars += len(chunk)
return "\n\n".join(context_parts)
def rag_query(
client: LLMClient,
query: str,
retrieved_chunks: list[str],
system_prompt: str | None = None,
) -> dict[str, str | int]:
"""Execute a RAG query and return structured result."""
context = build_rag_context(retrieved_chunks, query)
prompt = (
f"\n{context}\n \n\n"
f"Answer the following question using only the provided context:\n{query}"
)
answer = client.complete(prompt, max_tokens=1024)
report = client.usage_report()
return {
"answer": answer,
"sources_used": len(retrieved_chunks),
"total_tokens": report.get("total_tokens", 0),
}
Protocol (introduced in Python 3.8) implements structural subtyping — like a C# interface, but without requiring explicit inheritance. Any class that has the right methods satisfies the protocol, even if it doesn't inherit from it. This makes testing with mocks much easier: your mock doesn't need to inherit from anything, just implement the methods.
Type Stubs and Third-Party Libraries
Some libraries ship with type stubs (py.typed marker); others need separate stub packages:
# Check if a library has stubs
pip install openai # includes py.typed — fully typed ✓
pip install openai # includes py.typed ✓
# Libraries without stubs — install separately
pip install types-requests types-PyYAML pandas-stubs
# Or tell mypy to ignore the library
# In pyproject.toml:
# [[tool.mypy.overrides]]
# module = "some_untyped_lib.*"
# ignore_missing_imports = true
Practical Typing Patterns for AI Code
A few typing tools pay for themselves repeatedly in AI work. Literal constrains a value to a fixed set of strings (model names, roles) so a typo is a checker error, not a runtime surprise. TypeAlias gives a meaningful name to an otherwise cryptic type, and NamedTuple offers a lightweight immutable record. The patterns below are the ones you'll copy into most projects.
from typing import Literal, TypeAlias, NamedTuple, overload
# Type aliases for complex types
ModelName: TypeAlias = str
EmbeddingVector: TypeAlias = list[float]
MessageHistory: TypeAlias = list[dict[str, str]]
# Literal types for constrained strings
FinishReason = Literal["end_turn", "max_tokens", "stop_sequence"]
ModelTier = Literal["haiku", "sonnet", "opus"]
# NamedTuple for simple data with field names
class EvalResult(NamedTuple):
prompt_id: str
score: float
latency_ms: float
model: ModelName
# Function overloads — different return types based on input
@overload
def get_response(raw: Literal[True]) -> openai.types.chat.ChatCompletion: ...
@overload
def get_response(raw: Literal[False]) -> str: ...
def get_response(raw: bool = False) -> openai.types.chat.ChatCompletion | str:
response = client.chat.completions.create(...)
return response if raw else response.choices[0].message.content
Key Takeaways
- Type hints are Python annotations — they don't affect runtime, but enable static analysis and IDE intelligence
- Use built-in generics (
list[str],dict[str, int]) in Python 3.9+; import fromtypingfor 3.8 compatibility -
X | None(3.10+) orOptional[X]for nullable types — equivalent tostring?in C# 8+ - mypy for CI type checking; Pyright/Pylance for editor experience — use both
-
Protocolenables structural typing (duck-typing with type safety) — prefer it over ABC for testable AI client abstractions - Start with permissive mypy settings and gradually tighten per-module — forcing strict on a legacy codebase produces too much noise to be useful

Comments
Loading comments…