TechWayFit
● Complete Learning Series Python & AI Engineering for .NET Developers

Type Hinting & Static Analysis

Tech Buddy July 13, 2026 3 min read
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

C# (compiled types)
													// Types enforced at compile time
                      string Summarize(string text, int maxTokens) { ... }
                      
                      List<string> ExtractKeywords(string text) { ... }
                      
                      Dictionary<string, float> ScoreResponses(
                          List<string> responses) { ... }
                      												
Python (hints, not enforced at runtime)
													# 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),
                          }
                      										

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 from typing for 3.8 compatibility
  • X | None (3.10+) or Optional[X] for nullable types — equivalent to string? in C# 8+
  • mypy for CI type checking; Pyright/Pylance for editor experience — use both
  • Protocol enables 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…

Leave a comment