Hello. In the previous lesson, you turned vlm_lab into an installable package and installed it in an isolated environment. That structure now gives us a stable place to add a development tool: a static type checker.
This lesson makes the interfaces in that package explicit. You will annotate function inputs, outputs, and the collections flowing through a small vision–language utility; then you will use mypy to detect interface violations before running the program. This is an important engineering habit for AI systems, where a mistaken str versus list[str], logits versus probabilities, or image metadata versus pixels can otherwise travel a long way before failing.
What type hints do—and do not do
Python remains dynamically typed. A value has a type at runtime, but ordinary annotations do not make Python reject a wrong value automatically.
def build_prompt(image_context: str, question: str) -> str:
return (
"Image context: "
+ image_context
+ "\nQuestion: "
+ question
)
The signature says:
image_contextshould be astr;questionshould be astr;- the function produces a
str.
This is a contract for readers, IDEs, and tools such as mypy. But Python itself will still let you call:
build_prompt(["A red bicycle."], "What is the main object?")
The call is syntactically legal Python. It will only fail later, when Python tries to concatenate a string with a list. A static checker can identify the mismatch without executing the program.
Think of a type annotation as a claim about the interface, and mypy as a program that checks whether the claims are mutually consistent across the code it can analyze. It does not prove that your model is accurate, that a file exists, or that an API response has the promised structure at runtime. Those concerns need tests and, later, runtime validation. But it is very effective at catching many ordinary wiring mistakes early.
Python Tutorial: Type Hints - From Basic Annotations to Advanced Generics
Watch “Python Tutorial: Type Hints - From Basic Annotations to Advanced Generics” from Corey Schafer for a concise orientation to annotations, mypy, and optional values. It reinforces the distinction between documenting types and enforcing them at runtime.
Watch the overview for the role and limits of type hints. Then watch basic signatures to see parameter and return annotations catch incompatible calls. Finish with optional values, focusing on why a default of None requires a type that explicitly includes None.
Add mypy to the project
A type checker is a development dependency: it helps build and maintain the project but is not required by someone merely importing vlm_lab. Add the following sections to your existing pyproject.toml, after the existing [project] table.
[project.optional-dependencies]
dev = [
"mypy>=1.10",
]
[tool.mypy]
python_version = "3.11"
strict = true
files = ["src"]
warn_unused_ignores = true
The settings have deliberate roles:
python_version = "3.11"tells mypy that modern syntax such aslist[str]andstr | Noneis available.files = ["src"]makes the source package the checking target.strict = trueasks mypy to flag missing function annotations and several other ways type information can silently disappear.warn_unused_ignores = trueprevents stale# type: ignorecomments from accumulating unnoticed.
With your .venv activated, reinstall the project with its development extra:
python -m pip install -e ".[dev]"
Confirm that mypy is installed into the same environment as the package:
python -m mypy --version
Then run an initial check:
python -m mypy src
At this moment, the old unannotated build_prompt function will likely fail under strict mode because it lacks a complete type signature. That is expected: strict mode is showing you where the contract has not yet been written.
Type function boundaries first
For production code, annotate function boundaries before adding annotations to every local variable. A function signature tells callers what they may provide and tells maintainers what they can safely expect back.
Update src/vlm_lab/prompts.py:
"""Prompt construction and lightweight prompt analysis utilities."""
from collections.abc import Iterable, Mapping
def build_prompt(image_context: str, question: str) -> str:
return (
"Image context: "
+ image_context
+ "\nQuestion: "
+ question
)
def keyword_counts(text: str, keywords: Iterable[str]) -> dict[str, int]:
counts: dict[str, int] = {}
normalized_text = text.lower()
for keyword in keywords:
normalized_keyword = keyword.strip().lower()
if normalized_keyword:
counts[normalized_keyword] = normalized_text.count(normalized_keyword)
return counts
def labels_above_threshold(
scores: Mapping[str, float],
threshold: float = 0.5,
) -> list[str]:
return [
label
for label, score in scores.items()
if score >= threshold
]
Run the checker again:
python -m mypy src
A successful result looks like:
Success: no issues found in 2 source files
The exact number of files can differ as the package grows.
Read each signature as a contract
Consider:
def keyword_counts(text: str, keywords: Iterable[str]) -> dict[str, int]:
It means:
| Component | Meaning |
|---|---|
text: str |
The source text is one string. |
keywords: Iterable[str] |
The function can consume strings from any iterable: a list, tuple, set, generator, or similar object. |
-> dict[str, int] |
The result maps normalized keyword strings to integer occurrence counts. |
The choice of Iterable[str] is intentional. The function only loops over keywords; it does not index it, measure its length, or mutate it. Requiring list[str] would needlessly reject useful inputs such as a generator. This matters for future dataset pipelines, where a function that accepts an iterable can work with streamed samples rather than requiring everything in memory.
Likewise:
def labels_above_threshold(
scores: Mapping[str, float],
threshold: float = 0.5,
) -> list[str]:
uses Mapping[str, float], not dict[str, float], because the function only reads keys and values. A normal dictionary satisfies this interface, but so can another read-only mapping implementation. This principle is useful when designing reusable functions:
Accept the least demanding interface your function needs; return the most specific useful result you can promise.
Type hints cheat sheet - mypy 1.19.1 documentation
Read the relevant parts of the mypy documentation’s “Type hints cheat sheet.” It provides a compact reference for function signatures and for choosing flexible collection interfaces.
In the Functions section, read function signatures, including examples of parameters, return values, defaults, and functions returning None. Then go to Standard “duck types” and read the duck type rationale. Focus on the distinction between Iterable, Sequence, Mapping, and MutableMapping; for this lesson, Iterable and Mapping are the useful defaults for read-only inputs.
Annotating collections precisely
Container annotations answer two questions:
- What container shape is this?
- What types may its contents have?
Modern Python uses built-in collection names directly:
candidate_labels: list[str] = ["bicycle", "tree", "road"]
label_scores: dict[str, float] = {
"bicycle": 0.93,
"tree": 0.68,
}
image_size: tuple[int, int] = (1024, 768)
caption: str | None = None
These annotations have different meanings:
list[str]: a mutable sequence whose elements are strings.dict[str, float]: a dictionary mapping string keys to float values.tuple[int, int]: exactly two integers, appropriate for a width–height pair.str | None: either a string or the valueNone.
The final case is important. This signature is incorrect:
def format_caption(caption: str = None) -> str:
return caption.upper()
It claims caption is always a string, but gives it None as a default. State the possibility honestly:
def format_caption(caption: str | None = None) -> str:
if caption is None:
return "No caption available."
return caption.upper()
The if caption is None check does two jobs: it handles the runtime case and gives mypy enough evidence to treat caption as a str after the conditional.
Why empty collections need help
Mypy can usually infer a type from a nonempty assignment:
labels = ["bicycle", "tree"] # inferred as list[str]
But it cannot infer the intended element type from an empty list:
labels = []
Should that become list[str], list[float], or something else? Declare the intended type:
labels: list[str] = []
scores: dict[str, float] = {}
This appears constantly in data and model code. For example, a list that will accumulate batch losses should be declared as list[float]; a cache of token IDs might be dict[str, list[int]]. The annotation records the intended shape at the point where it is otherwise ambiguous.
Type inference and type annotations - mypy 1.20.1 documentation
Use the mypy documentation’s “Type inference and type annotations” page to distinguish what mypy can infer automatically from what you need to declare explicitly.
In Type inference, read variable inference. Notice the warning that local variables in untyped functions lose useful checking. Next, in Explicit types for collections, read from the explanation of empty lists and dictionaries through the examples of typed empty containers. In particular, locate the empty container fix, then read the following dictionary example as well.
Make mypy catch a real interface error
Create a temporary file named scratch_types.py in the project root:
from vlm_lab import build_prompt
prompt = build_prompt(
["A red bicycle beside a tree."],
"What is the main object?",
)
Now check it:
python -m mypy scratch_types.py
Mypy should report an error resembling:
scratch_types.py:3: error: Argument 1 to "build_prompt" has incompatible type "list[str]"; expected "str" [arg-type]
Found 1 error in 1 file
The key information is not the line number; it is the explanation:
- you supplied a
list[str]; build_promptpromised to accept astr;- mypy identifies the incompatible boundary before execution.
Correct the call:
from vlm_lab import build_prompt
prompt = build_prompt(
"A red bicycle beside a tree.",
"What is the main object?",
)
Then rerun mypy. It should pass.
Delete scratch_types.py afterward, or keep it only if you deliberately want a small local experiment. Your package source should remain clean and type-correct.
A practical repair sequence
When mypy reports an error, avoid treating it as a nuisance to suppress. Work through this sequence:
- Read the expected and actual types. The checker usually names both.
- Locate the boundary that should change. Is the caller wrong, the function contract wrong, or is a conversion missing?
- Make the data representation honest. For example, join
list[str]into a string only if joining is semantically correct. - Run mypy again.
- Run the relevant program or tests. Type correctness does not guarantee behavioral correctness.
Avoid using Any, cast, or # type: ignore merely to get a green result. They can be appropriate at a genuinely dynamic or incorrectly typed third-party boundary, but using them on ordinary application code removes exactly the protection you are trying to add. With warn_unused_ignores = true, even no-longer-needed ignores will be reported.
A small annotation guide for this project
Use this reference while building the course project:
| Situation | Preferred annotation |
|---|---|
| A text prompt | str |
| A numeric score | float |
| Optional decoded caption | str | None |
| Token IDs for one sequence | list[int] |
| A width–height pair | tuple[int, int] |
| Label-to-score output | dict[str, float] |
| Read-only incoming label scores | Mapping[str, float] |
| Any source of strings consumed once or repeatedly | Iterable[str] |
| A function with no meaningful result | -> None |
A useful dividing line is ownership and mutation:
- If your function creates and returns a new collection, concrete types such as
list[str]anddict[str, float]clearly describe what callers receive. - If your function receives a collection and only reads it, use an abstract interface such as
Iterable[str]orMapping[str, float]. - If your function truly needs to append to the caller’s list or assign into its dictionary, its parameter type should communicate that mutable requirement explicitly. We will return to data-flow and mutation choices when discussing efficient numerical programs.
Key takeaways
Python type hints are executable metadata, not runtime enforcement. Their value appears when a static checker such as mypy compares the contracts at function boundaries and finds mismatches before execution.
You have now:
- configured mypy as a development dependency in
pyproject.toml; - enabled strict checking for the package under
src/; - annotated function parameters and return values;
- used
list,dict,tuple, union,Iterable, andMappingannotations; - explicitly typed empty collections; and
- used mypy to find and fix an incompatible call.
In the next lesson, you will use type-annotated dataclasses to represent model and experiment configuration, then add validation so invalid settings are rejected rather than merely described.