Most LLM call sites start the same way: a string, built by hand, with the input variables spliced into it, and a sentence at the end describing the shape of the output you want. It works, until someone edits the string. Change a word in the instructions, reorder a sentence, add a clarifying example, and the model’s output format can shift in ways nothing in the codebase will catch. The contract between “what this function is supposed to produce” and “what’s actually inside this prompt string” lives entirely in prose, which means it’s checked by nothing until a downstream parser throws or, worse, silently gets the wrong field.
Declaring the contract instead of writing it
DSPy’s answer to this is the signature: a class that declares typed input and output fields, with the framework responsible for turning that declaration into an actual prompt at call time. Here’s a real one, from a research agent’s planning step (src/agents/planner.py):
class PlannerSignature(dspy.Signature):
"""Break down the user's technical research topic into 3 specific web search queries.
Target documentation, architecture patterns, and tutorials."""
topic: str = dspy.InputField()
queries: list[str] = dspy.OutputField(
desc="Exactly 3 highly specific web search queries"
)
Nothing here is a prompt. It’s a description of the task (the docstring), a typed input, and a typed output with a description of what should be in it. The module that actually runs it is a couple of lines:
_planner_module = dspy.ChainOfThought(PlannerSignature)
result = planner(topic=topic)
ChainOfThought is the prompting strategy: it takes the signature and constructs a prompt that asks the model to reason step by step before producing queries. dspy.Predict(PlannerSignature) would build a plainer, non-reasoning prompt from the same signature. Neither the caller (plan_node) nor the signature itself changes when you swap between them; result.queries is still a list[str] either way.
The same project’s critic step shows what typed outputs buy you when the output isn’t just text:
class CriticSignature(dspy.Signature):
"""Evaluate the draft report against the raw sources.
Check for hallucinations and comprehensiveness."""
topic: str = dspy.InputField()
raw_sources: str = dspy.InputField()
draft_report: str = dspy.InputField()
score: int = dspy.OutputField(desc="Score from 1 to 10")
is_hallucinated: bool = dspy.OutputField(
desc="True if the report contains facts not in the sources"
)
feedback: str = dspy.OutputField(
desc="Specific revision feedback, empty string if score >= 7"
)
The caller doesn’t regex a score out of a sentence or hope the model formatted a JSON blob correctly. It reads result.score as an int and result.is_hallucinated as a bool, and routes on them directly:
if result.score < 7 or result.is_hallucinated:
return {"critic_feedback": result.feedback, "revision_count": revision_count + 1, ...}
What this actually guarantees, and what it doesn’t
What a typed signature gives you is a structural guarantee on shape, not a semantic guarantee on correctness. result.score will be an int; DSPy’s output parsing enforces that before your code ever sees it. Whether that int is a good score, whether the critic actually caught the hallucination it was supposed to catch, is a separate question a type system has no opinion on. Swapping dspy.Predict for dspy.ChainOfThought, or later swapping in an optimizer that rewrites the underlying prompt to improve accuracy on a training set, changes how the answer gets produced without changing the contract the rest of the graph depends on. That’s the actual payoff: the prompting strategy becomes a decision you can revisit in one place, instead of a string you have to hunt down and re-edit at every call site that happens to construct a similar prompt by hand.
What it doesn’t do is replace the work of writing a good task description, choosing informative field names, or providing the field-level desc hints that steer the model toward what you actually mean by “score” or “hallucinated.” Those are still prompt engineering, just declared as structured metadata instead of buried in prose. A badly specified signature produces a badly performing module in exactly the way a badly written prompt does; the difference is that when it does, you can point at the exact field that’s underspecified instead of re-reading an entire prompt string looking for the sentence that’s ambiguous.
The failure mode this actually forecloses is the quiet one: a prompt edit that changes formatting behavior without anyone noticing until a parser downstream breaks in production. A typed output field either parses to the declared type or the call fails loudly. That’s a small guarantee, but it’s the one that raw prompt strings never gave you for free.