The Complete Guide to DSPy Signatures in Ax
Introduction: Why Signatures Beat Prompts
Traditional prompt engineering is like writing assembly code – tedious, fragile, and requires constant tweaking. DSPy signatures are like high-level programming – you describe what you want, not how to get it.
The Problem with Prompts
// ❌ Traditional approach - fragile and verbose
const prompt = `You are a sentiment analyzer. Given a customer review,
analyze the sentiment and return exactly one of: positive, negative, or neutral.
Be sure to only return the sentiment word, nothing else.
Review: ${review}
Sentiment:`;
// Hope the LLM follows instructions...
The Power of Signatures
// ✅ Signature approach - clear and type-safe
const analyzer = ax('review:string -> sentiment:class "positive, negative, neutral"');
// Guaranteed structured output with TypeScript types!
const result = await analyzer.forward(llm, { review });
console.log(result.sentiment); // TypeScript knows this is "positive" | "negative" | "neutral"
Understanding Signature Syntax
A signature defines the contract between your code and the LLM:
[description] input1:type, input2:type -> output1:type, output2:type
Basic Structure
- Optional Description: Overall purpose in quotes
- Input Fields: What you provide to the LLM
- Arrow (
->
): Separates inputs from outputs - Output Fields: What the LLM returns
Examples
// Simple Q&A
'userQuestion:string -> aiAnswer:string'
// With description
'"Answer questions about TypeScript" question:string -> answer:string, confidence:number'
// Multiple inputs and outputs
'document:string, query:string -> summary:string, relevantQuotes:string[]'
Field Types Reference
Ax supports a rich type system that maps directly to TypeScript types:
Basic Types
Type | Signature Syntax | TypeScript Type | Example |
---|---|---|---|
String | :string | string | userName:string |
Number | :number | number | score:number |
Boolean | :boolean | boolean | isValid:boolean |
JSON | :json | any | metadata:json |
Date and Time Types
Type | Signature Syntax | TypeScript Type | Example |
---|---|---|---|
Date | :date | Date | birthDate:date |
DateTime | :datetime | Date | timestamp:datetime |
Media Types (Input Only)
Type | Signature Syntax | TypeScript Type | Example |
---|---|---|---|
Image | :image | {mimeType: string, data: string} | photo:image |
Audio | :audio | {format?: 'wav', data: string} | recording:audio |
File | :file | {mimeType: string, data: string} | document:file |
URL | :url | string | website:url |
Special Types
Type | Signature Syntax | TypeScript Type | Example |
---|---|---|---|
Code | :code | string | pythonScript:code |
Classification | :class "opt1, opt2" | "opt1" | "opt2" | mood:class "happy, sad, neutral" |
Arrays and Optional Fields
Arrays
Add []
after any type to make it an array:
// String array
'tags:string[] -> processedTags:string[]'
// Number array
'scores:number[] -> average:number, median:number'
// Classification array
'documents:string[] -> categories:class[] "news, blog, tutorial"'
Optional Fields
Add ?
before the colon to make a field optional:
// Optional input
'query:string, context?:string -> response:string'
// Optional output
'text:string -> summary:string, keywords?:string[]'
// Both optional
'message?:string -> reply?:string, confidence:number'
Advanced Features
Internal Fields (Output Only)
Use !
to mark output fields as internal (for reasoning/chain-of-thought):
// Internal fields are hidden from the final output but guide LLM reasoning
'problem:string -> reasoning!:string, solution:string'
Classification Fields (Output Only)
Classifications provide type-safe enums:
// Single classification
'email:string -> priority:class "urgent, normal, low"'
// Multiple options with pipe separator
'text:string -> sentiment:class "positive | negative | neutral"'
// Array of classifications
'reviews:string[] -> sentiments:class[] "positive, negative, neutral"'
Creating Signatures: Three Approaches
1. String-Based (Recommended)
import { ax, s } from '@ax-llm/ax';
// Direct generator creation
const generator = ax('input:string -> output:string');
// Create signature first, then generator
const sig = s('query:string -> response:string');
const gen = ax(sig.toString());
2. Pure Fluent Builder API
import { f } from '@ax-llm/ax';
// Using the pure fluent builder - only supports .optional(), .array(), .internal()
const signature = f()
.input('userMessage', f.string('User input'))
.input('contextData', f.string('Additional context').optional())
.input('tags', f.string('Keywords').array())
.input('categories', f.string('Categories').optional().array())
.output('responseText', f.string('AI response'))
.output('confidenceScore', f.number('Confidence score 0-1'))
.output('debugInfo', f.string('Debug information').internal())
.build();
3. Hybrid Approach
import { s, f } from '@ax-llm/ax';
// Start with string, add fields programmatically
const sig = s('base:string -> result:string')
.appendInputField('extra', f.optional(f.json('Metadata')))
.appendOutputField('score', f.number('Quality score'));
Pure Fluent API Reference
The fluent API has been redesigned to be purely fluent, meaning you can only use method chaining with .optional()
, .array()
, and .internal()
methods. Nested function calls are no longer supported.
✅ Pure Fluent Syntax (Current)
import { f } from '@ax-llm/ax';
// Basic field types
const stringField = f.string('description');
const numberField = f.number('description');
const booleanField = f.boolean('description');
// Array types - use .array() method chaining
const stringArray = f.string('array description').array();
const numberArray = f.number('array description').array();
const booleanArray = f.boolean('array description').array();
// Optional fields - use .optional() method chaining
const optionalString = f.string('optional description').optional();
const optionalArray = f.string('optional array').optional().array();
const arrayOptional = f.string('array optional').array().optional(); // Same as above
// Internal fields (output only) - use .internal() method chaining
const internalField = f.string('internal description').internal();
const internalArray = f.string('internal array').array().internal();
// Complex combinations
const complexField = f.string('complex field')
.optional() // Make it optional
.array() // Make it an array
.internal(); // Mark as internal (output only)
❌ Deprecated Nested Syntax (Removed)
// These no longer work and will cause compilation errors
const badArray = f.array(f.string('description')); // ❌ Removed
const badOptional = f.optional(f.string('description')); // ❌ Removed
const badInternal = f.internal(f.string('description')); // ❌ Removed
String vs Fluent API Equivalence
Both approaches create identical signatures:
// String syntax
const stringSig = AxSignature.create(`
userMessages:string[] "User messages",
maxTokens?:number "Max tokens",
enableDebug:boolean "Debug mode",
categories?:string[] "Optional categories"
->
responseText:string "Response",
debugInfo!:string "Debug info"
`);
// Equivalent fluent syntax
const fluentSig = f()
.input('userMessages', f.string('User messages').array())
.input('maxTokens', f.number('Max tokens').optional())
.input('enableDebug', f.boolean('Debug mode'))
.input('categories', f.string('Optional categories').optional().array())
.output('responseText', f.string('Response'))
.output('debugInfo', f.string('Debug info').internal())
.build();
// Both create identical runtime structures and TypeScript types
console.log(stringSig.toString() === fluentSig.toString()); // true
Type Inference and Arrays
The fluent API properly maps to TypeScript array types:
// These all correctly infer TypeScript types
const sig = f()
.input('strings', f.string('strings').array()) // string[]
.input('numbers', f.number('numbers').array()) // number[]
.input('booleans', f.boolean('booleans').array()) // boolean[]
.input('optionalStrings', f.string('optional').optional().array()) // string[] | undefined
.output('responseText', f.string('response')) // string
.build();
// TypeScript knows the exact types at compile time
type InputType = {
strings: string[];
numbers: number[];
booleans: boolean[];
optionalStrings?: string[];
responseText: string;
};
Field Naming Best Practices
Ax enforces descriptive field names to improve LLM understanding:
✅ Good Field Names
userQuestion
,customerEmail
,documentText
analysisResult
,summaryContent
,responseMessage
confidenceScore
,categoryType
,priorityLevel
❌ Bad Field Names (Will Error)
text
,data
,input
,output
(too generic)a
,x
,val
(too short)1field
,123name
(starts with number)
Real-World Examples
Email Classifier
const emailClassifier = ax(`
emailSubject:string "Email subject line",
emailBody:string "Full email content" ->
category:class "sales, support, spam, newsletter" "Email category",
priority:class "urgent, normal, low" "Priority level",
summary:string "Brief summary of the email"
`);
const result = await emailClassifier.forward(llm, {
emailSubject: "Urgent: Server Down",
emailBody: "Our production server is experiencing issues..."
});
console.log(result.category); // "support"
console.log(result.priority); // "urgent"
Document Analyzer with Chain-of-Thought
const analyzer = ax(`
documentText:string "Document to analyze" ->
reasoning!:string "Step-by-step analysis",
mainTopics:string[] "Key topics discussed",
sentiment:class "positive, negative, neutral, mixed" "Overall tone",
readability:class "elementary, high-school, college, graduate" "Reading level",
keyInsights:string[] "Important takeaways"
`);
// The reasoning field guides the LLM but isn't returned
const result = await analyzer.forward(llm, {
documentText: "..."
});
// result.reasoning is undefined (internal field)
// result.mainTopics, sentiment, etc. are available
Multi-Modal Analysis
const imageAnalyzer = ax(`
imageData:image "Image to analyze",
question?:string "Specific question about the image" ->
description:string "What's in the image",
objects:string[] "Identified objects",
textFound?:string "Any text detected in the image",
answerToQuestion?:string "Answer if question was provided"
`);
Data Extraction
const extractor = ax(`
invoiceText:string "Raw invoice text" ->
invoiceNumber:string "Invoice ID",
invoiceDate:date "Date of invoice",
dueDate:date "Payment due date",
totalAmount:number "Total amount due",
lineItems:json[] "Array of {description, quantity, price}",
vendor:json "{ name, address, taxId }"
`);
Pure Fluent API Example
import { f, ax } from '@ax-llm/ax';
// Complex signature using pure fluent API
const contentAnalyzer = f()
.input('articleText', f.string('Article content to analyze'))
.input('authorInfo', f.json('Author metadata').optional())
.input('keywords', f.string('Target keywords').array())
.input('checkFactuality', f.boolean('Enable fact-checking'))
.output('mainThemes', f.string('Key themes').array())
.output('sentimentScore', f.number('Sentiment score -1 to 1'))
.output('readabilityLevel', f.class(['elementary', 'middle', 'high', 'college'], 'Reading level'))
.output('factChecks', f.json('Fact checking results').array().optional())
.output('processingTime', f.number('Analysis time in ms').internal())
.description('Comprehensive article analysis with optional fact-checking')
.build();
// Create generator from fluent signature
const generator = ax(contentAnalyzer.toString());
// Usage with typed inputs/outputs
const result = await generator.forward(llm, {
articleText: 'Sample article content...',
keywords: ['AI', 'machine learning', 'technology'],
checkFactuality: true,
// authorInfo is optional
});
// TypeScript knows exact types
console.log(result.mainThemes); // string[]
console.log(result.sentimentScore); // number
console.log(result.readabilityLevel); // 'elementary' | 'middle' | 'high' | 'college'
console.log(result.factChecks); // json[] | undefined (optional)
// result.processingTime is undefined (internal field)
Streaming Support
All signatures support streaming by default:
const storyteller = ax(`
prompt:string "Story prompt",
genre:class "fantasy, sci-fi, mystery, romance" ->
title:string "Story title",
story:string "The complete story",
wordCount:number "Approximate word count"
`);
// Stream the response
for await (const chunk of storyteller.stream(llm, {
prompt: "A detective discovers their partner is a time traveler",
genre: "mystery"
})) {
if (chunk.story) {
process.stdout.write(chunk.story); // Real-time streaming
}
}
Type Safety and IntelliSense
Signatures provide full TypeScript type inference:
const typed = ax(`
userId:number,
includeDetails?:boolean ->
userName:string,
userEmail:string,
metadata?:json
`);
// TypeScript knows the exact types
const result = await typed.forward(llm, {
userId: 123, // ✅ number required
includeDetails: true // ✅ boolean optional
// userEmail: "..." // ❌ TypeScript error: not an input field
});
console.log(result.userName); // ✅ TypeScript knows this is string
console.log(result.metadata?.x); // ✅ TypeScript knows this is any | undefined
// console.log(result.userId); // ❌ TypeScript error: not an output field
Common Patterns
1. Chain of Thought Reasoning
// Use internal fields for reasoning steps
const reasoner = ax(`
problem:string ->
thoughts!:string "Internal reasoning process",
answer:string "Final answer"
`);
2. Structured Data Extraction
// Extract structured data from unstructured text
const parser = ax(`
messyData:string ->
structured:json "Clean JSON representation"
`);
3. Multi-Step Classification
// Hierarchical classification
const classifier = ax(`
text:string ->
mainCategory:class "technical, business, creative",
subCategory:class "based on main category",
confidence:number "0-1 confidence score"
`);
4. Validation and Checking
// Validate and explain
const validator = ax(`
code:code "Code to review",
language:string "Programming language" ->
isValid:boolean "Is the code syntactically correct",
errors?:string[] "List of errors if any",
suggestions?:string[] "Improvement suggestions"
`);
Error Handling
Signatures provide clear, actionable error messages:
// ❌ This will throw a descriptive error
try {
const bad = ax('text:string -> result:string');
} catch (error) {
// Error: Field name "text" is too generic.
// Use a more descriptive name like "inputText" or "documentText"
}
// ❌ Invalid type
try {
const bad = ax('userInput:str -> result:string');
} catch (error) {
// Error: Unknown type "str". Did you mean "string"?
}
// ❌ Duplicate field names
try {
const bad = ax('data:string, data:number -> result:string');
} catch (error) {
// Error: Duplicate field name "data" in inputs
}
Migration from Traditional Prompts
Before (Prompt Engineering)
const prompt = `
Analyze the sentiment of the following review.
Rate it on a scale of 1-5.
Identify the main topics discussed.
Format your response as JSON with keys: rating, sentiment, topics
Review: ${review}
`;
const response = await llm.generate(prompt);
const parsed = JSON.parse(response); // Hope it's valid JSON...
After (Signatures)
const analyzer = ax(`
review:string ->
rating:number "1-5 rating",
sentiment:class "very positive, positive, neutral, negative, very negative",
topics:string[] "Main topics discussed"
`);
const result = await analyzer.forward(llm, { review });
// Guaranteed structure, no parsing needed!
Performance Tips
- Use specific types:
class
is more token-efficient thanstring
for enums - Leverage arrays: Process multiple items in one call
- Optional fields: Only request what you need
- Internal fields: Use
!
for reasoning without returning it
Conclusion
DSPy signatures in Ax transform LLM interactions from fragile prompt engineering to robust, type-safe programming. By describing what you want instead of how to get it, you can:
- Write more maintainable code
- Get guaranteed structured outputs
- Leverage TypeScript’s type system
- Switch LLM providers without changing logic
- Build production-ready AI features faster
Start using signatures today and experience the difference!