Summary
Imperative prompts that specify step-by-step instructions are fragile and verbose. Declarative prompts that declare constraints and desired state let the LLM determine the implementation path, resulting in more robust, flexible, and concise prompts that are easier to verify and less likely to fail when intermediate steps encounter issues.
The Problem
Imperative prompts with step-by-step instructions (“First do X, then Y, then Z”) are fragile, verbose, and fail when any intermediate step encounters an issue. They overconstrain the LLM’s approach and require extensive prompt maintenance when requirements change. These prompts also make it difficult to verify success since the process is specified rather than the outcome.
The Solution
Use declarative prompts that specify constraints and desired end state rather than implementation steps. State what MUST be true in the final result, what rules must be followed, and what conditions must be satisfied. Let the LLM choose the implementation path. This approach is more robust (LLM can adapt when steps fail), more concise (focus on ‘what’ not ‘how’), and easier to verify (check constraints, not steps).
The Problem with Imperative Prompts
Many developers write prompts the way they write code: as a series of step-by-step instructions.
Imperative prompt example:
First, read schema.ts to understand the User type.
Then, find the User type definition.
Then, add an email field with type string.
Then, add validation for email format using regex.
Then, update the insert function to include the email field.
Then, update the update function to handle email changes.
Then, write a test for email validation.
Then, run the tests to verify they pass.
This approach seems logical, but it has critical flaws:
Problem 1: Fragility
If any step fails, the entire workflow breaks:
Step 1: Read schema.ts ✅
Step 2: Find User type ❌ FAILED (file was renamed to types.ts)
Step 3: Add email field ❌ BLOCKED (can't find type)
Step 4: Add validation ❌ BLOCKED
...
All remaining steps: ❌ BLOCKED
Result: The LLM either stops and asks for help, or proceeds with incorrect assumptions, generating invalid code.
Problem 2: Verbosity
Imperative prompts require exhaustive detail:
- Every step must be explicit
- Edge cases need separate instructions
- Error handling requires additional steps
- Alternative paths multiply prompt length
Result: Prompts become 10-20 lines when 3-4 declarative constraints would suffice.
Problem 3: Over-Constraining
Imperative prompts dictate how to solve the problem, limiting the LLM’s ability to find better approaches:
Imperative: "Use a for-loop to iterate through users, then use if-statements to filter..."
Better approach LLM could have found: Array.filter() with functional composition
Result: Suboptimal implementations because the LLM follows your prescribed path rather than choosing the best one.
Problem 4: Difficult Verification
Imperative prompts specify process, not outcome:
How do you verify:
- "First read schema.ts"
- "Then find the User type"
- "Then add email field"
You'd need to check each step was performed correctly.
Result: Verification is complex and coupled to the implementation approach.
Problem 5: Maintenance Burden
When requirements change, imperative prompts require extensive updates:
Old requirement: "User has email field"
Imperative prompt: 8 steps detailing implementation
New requirement: "User has email AND phone fields"
Imperative prompt update: Modify all 8 steps + add 8 more for phone
Result: High maintenance cost as prompts grow and diverge from actual codebase.
The Declarative Solution
Declarative prompts specify what must be true, not how to achieve it.
Declarative prompt example:
User type MUST have email: string field.
Email MUST be validated with RFC 5322 format.
All type changes MUST have corresponding Zod schema updates.
All type changes MUST have test coverage.
Why Declarative is Better
Benefit 1: Robustness
The LLM can adapt its approach when obstacles arise:
Constraint: "User type MUST have email: string field"
LLM approach 1: Look in schema.ts
→ File not found
→ LLM adapts: Search for User type in other files
→ Finds types.ts
→ Adds email field
→ Constraint satisfied ✅
No explicit steps were specified, so the LLM isn’t blocked when the expected path doesn’t work.
Benefit 2: Conciseness
Imperative (8 lines):
1. Read schema.ts
2. Find User type
3. Add email field
4. Add validation regex
5. Update insert function
6. Update update function
7. Write tests
8. Run tests
Declarative (3 lines):
User MUST have email: string with RFC 5322 validation.
All type changes MUST have Zod schema updates and tests.
All CRUD operations MUST handle the new field.
Result: 62% reduction in prompt length while conveying the same requirements.
Benefit 3: Flexibility
The LLM chooses the best implementation path:
Declarative: "All exported functions MUST have JSDoc"
LLM can choose:
- Option 1: Add JSDoc manually to each function
- Option 2: Use automated tool to generate JSDoc
- Option 3: Use ESLint rule to enforce + auto-fix
LLM picks option 3 (most efficient)
Imperative prompts would have prescribed a specific approach, potentially missing the better solution.
Benefit 4: Easy Verification
Declarative constraints are directly verifiable:
// Constraint: "User type MUST have email: string"
const verification = {
hasEmailField: 'email' in User.shape,
emailIsString: User.shape.email._def.typeName === 'ZodString',
};
// Constraint: "Email MUST be validated with RFC 5322"
const hasRFC5322Validation =
User.shape.email._def.checks.some(c =>
c.kind === 'regex' && c.regex.test('[email protected]')
);
Result: Automated verification of constraints rather than manual process checking.
Benefit 5: Low Maintenance
Declarative constraints are requirement-focused:
Old requirement: "User has email field"
Declarative: "User MUST have email: string with validation"
New requirement: "User has email AND phone fields"
Declarative update: Add "User MUST have phone: string with E.164 validation"
→ Only 1 line added, existing constraints unchanged
Result: Minimal prompt maintenance as requirements evolve.
Implementation Patterns
Pattern 1: MUST Statements
Use “MUST” to declare non-negotiable constraints:
❌ Imperative:
"Add error handling to catch exceptions and return error objects"
✅ Declarative:
"All async functions MUST handle errors and return Result<T, E> type.
Errors MUST NOT be thrown; use Result.error() instead."
Pattern 2: Property-Based Constraints
Specify properties that must hold, not how to achieve them:
❌ Imperative:
"Loop through users array, check if age >= 18, filter out minors"
✅ Declarative:
"Returned users MUST all satisfy age >= 18.
Input array MUST NOT be modified."
Pattern 3: Invariants
State conditions that must remain true:
❌ Imperative:
"When adding items to cart, check inventory, decrement stock, update cart"
✅ Declarative:
"Cart total MUST equal sum of item prices.
Inventory MUST be decremented atomically with cart updates.
Cart operations MUST be transactional (all-or-nothing)."
Pattern 4: Format Constraints
Specify output format requirements:
❌ Imperative:
"Create a function, add JSDoc with @param and @returns tags, export it"
✅ Declarative:
"All exported functions MUST have JSDoc with complete @param and @returns.
All functions MUST follow naming convention: verbNoun (e.g., getUser).
All functions MUST be exported as const arrow functions."
Pattern 5: Relationship Constraints
Describe how elements must relate:
❌ Imperative:
"Import User type from types.ts, import Zod from zod, create schema matching User"
✅ Declarative:
"Zod schema MUST exactly match TypeScript type definition.
All schema fields MUST have same names and types as TypeScript interface.
Changes to types MUST be reflected in schemas automatically."
Real-World Examples
Example 1: API Endpoint Creation
❌ Imperative Approach:
First, create a new file in src/api/users/endpoints/
Then, import the tRPC router and user service
Then, define a procedure with input validation
Then, add authentication middleware
Then, implement the business logic
Then, handle errors with try-catch
Then, return a JSON response
Then, add the procedure to the router
Then, write integration tests
Then, update API documentation
✅ Declarative Approach:
API endpoint MUST:
- Accept input matching UserCreateInput schema
- Require authentication (JWT)
- Validate input with Zod
- Return UserCreateResult (never throw)
- Have integration test with 200 and 400 cases
- Follow existing API patterns in api/users/
Advantages:
- LLM can examine existing endpoints and match patterns
- Can adapt if file structure differs from expectation
- Clear success criteria (constraints satisfied or not)
- No prescribed implementation path
Example 2: Database Migration
❌ Imperative Approach:
Create a new migration file with timestamp prefix
Add ALTER TABLE statement to add email column
Set email column as VARCHAR(255)
Add NOT NULL constraint with default empty string
Add unique constraint on email
Create index on email for faster lookups
Add rollback statement to drop column
Test migration up and down
✅ Declarative Approach:
Migration MUST:
- Add email column to users table
- Email MUST be unique, non-null, indexed
- Migration MUST be reversible (up/down)
- Existing rows MUST have valid email values after migration
- Migration MUST follow project migration conventions
Advantages:
- LLM chooses appropriate SQL for the database (Postgres vs MySQL syntax)
- LLM determines best data type based on conventions
- LLM handles existing data appropriately
- Clear verification: constraints are met or not
Example 3: Component Refactoring
❌ Imperative Approach:
Open UserProfile.tsx
Find the inline styles
Extract styles to a separate object
Convert styles to CSS modules
Import the CSS module
Replace style props with className
Update all child components
Ensure styling looks identical
Test in browser
✅ Declarative Approach:
UserProfile component MUST:
- Use CSS modules (no inline styles)
- Maintain exact visual appearance
- Follow project styling conventions
- Have no TypeScript errors
- Have no style-related regressions in tests
Advantages:
- LLM can use existing CSS module patterns
- Can adapt if component structure differs
- Can choose between CSS modules, Tailwind, or other approaches if appropriate
- Verification is outcome-based (looks the same, no errors)
Example 4: Authentication Implementation
❌ Imperative Approach:
Install bcrypt and jsonwebtoken packages
Create auth service with hashPassword function
Use bcrypt.hash with salt rounds 10
Create comparePassword function with bcrypt.compare
Create generateToken function with jwt.sign
Set token expiry to 24 hours
Create verifyToken middleware
Add middleware to protected routes
Handle token refresh
Write tests for all auth functions
✅ Declarative Approach:
Authentication MUST:
- Hash passwords with industry-standard algorithm (bcrypt/argon2)
- Use JWT tokens with 24h expiry
- Validate tokens on protected routes
- Support token refresh
- Never store plaintext passwords
- Have test coverage for authentication flows
- Follow OWASP security guidelines
Advantages:
- LLM can choose between bcrypt, argon2, or other secure options
- Can adapt to existing auth patterns in codebase
- Security constraints are explicit
- Verification focuses on security properties, not specific libraries
Combining Declarative and Imperative
Sometimes a hybrid approach is appropriate:
When to Use Imperative Steps
-
Critical sequences where order matters for correctness:
Database migration MUST: 1. Create backup of users table 2. Add email column 3. Populate email from alternate source 4. Add NOT NULL constraint (Order matters: can't add NOT NULL before populating) -
Complex workflows with multiple distinct phases:
Deployment MUST: Phase 1: Run tests (block if failed) Phase 2: Build application Phase 3: Deploy to staging Phase 4: Run smoke tests Phase 5: Deploy to production -
When teaching the LLM a new pattern:
First time: Provide imperative steps to establish pattern Subsequent times: Use declarative constraints referencing the pattern
Best Practice: Declarative Goals with Imperative Guidance
## Constraints (Declarative)
- User type MUST have email field
- Email MUST be validated
- All CRUD operations MUST handle email
- Changes MUST have test coverage
## Suggested Approach (Imperative Guidance)
If you're unsure how to proceed:
1. Check types.ts for User definition
2. Look at existing fields for validation patterns
3. Update repository methods to include email
4. Follow test patterns from other field additions
You may deviate from this approach if you find a better solution.
Benefits:
- Constraints are clear and verifiable
- Guidance provides a starting point
- LLM has flexibility to adapt
- Explicitly permits deviation for better solutions
Verification Strategies
Declarative constraints enable automated verification:
Strategy 1: Static Analysis
// Constraint: "All exported functions MUST have JSDoc"
import { Project } from 'ts-morph';
const project = new Project({ tsConfigFilePath: 'tsconfig.json' });
const violations = [];
for (const sourceFile of project.getSourceFiles()) {
for (const func of sourceFile.getFunctions()) {
if (func.isExported() && !func.getJsDocs().length) {
violations.push(`${sourceFile.getFilePath()}:${func.getName()}`);
}
}
}
if (violations.length > 0) {
throw new Error(`Missing JSDoc: ${violations.join(', ')}`);
}
Strategy 2: Type Checking
// Constraint: "User type MUST have email: string field"
import { User } from './types';
import { expectType } from 'tsd';
// This will fail to compile if constraint is violated
expectType<string>(({} as User).email);
Strategy 3: Test Assertions
// Constraint: "All async functions MUST return Result<T, E>"
import { isResult } from './result';
describe('Constraint: Result return type', () => {
it('authenticate returns Result', async () => {
const result = await authenticate('test', 'pass');
expect(isResult(result)).toBe(true);
});
});
Strategy 4: Runtime Checks
// Constraint: "Cart total MUST equal sum of item prices"
function verifyCartInvariants(cart: Cart) {
const expectedTotal = cart.items.reduce((sum, item) =>
sum + item.price * item.quantity, 0
);
if (cart.total !== expectedTotal) {
throw new Error(
`Invariant violated: cart.total (${cart.total}) !== sum of items (${expectedTotal})`
);
}
}
Common Pitfalls
❌ Pitfall 1: Vague Constraints
Bad:
Code SHOULD be clean and maintainable
Good:
All functions MUST be <50 lines.
All functions MUST have single responsibility.
All complex logic MUST have explaining comments.
Why: Vague constraints can’t be verified. Specific constraints can.
❌ Pitfall 2: Mixing Concerns
Bad:
User type MUST have email field validated with regex and stored in database with unique constraint and shown in UI with proper formatting
Good:
## Type Constraints
User MUST have email: string field
## Validation Constraints
Email MUST match RFC 5322 format
## Database Constraints
Email column MUST have unique constraint
## UI Constraints
Email MUST be displayed with mailto: link
Why: Separate concerns enable modular verification and clearer requirements.
❌ Pitfall 3: Over-Specifying Implementation
Bad:
Use bcrypt with 10 salt rounds to hash passwords
Good:
Passwords MUST be hashed with industry-standard algorithm.
Hash strength MUST meet OWASP recommendations.
Why: Lets LLM choose best available option (bcrypt, argon2, etc.) and adapt as standards evolve.
❌ Pitfall 4: Missing Success Criteria
Bad:
Implement user authentication
Good:
Authentication MUST:
- Accept email + password
- Return JWT token on success
- Return error message on failure
- Hash passwords before storage
- Have tests for success and failure cases
Why: Clear success criteria enable verification and prevent ambiguity.
❌ Pitfall 5: Ignoring Edge Cases
Bad:
User MUST have email field
Good:
User MUST have email field.
Email MUST be unique across all users.
Email MUST handle case-insensitivity ([email protected] === [email protected]).
Email updates MUST revalidate uniqueness.
Why: Edge cases cause production bugs. Declare them as constraints.
Integration with Other Patterns
Combine with Custom ESLint Rules
Use ESLint to enforce declarative constraints:
// Constraint: "All exported functions MUST be arrow functions"
module.exports = {
rules: {
'only-arrow-functions': {
create(context) {
return {
FunctionDeclaration(node) {
if (node.parent.type === 'ExportNamedDeclaration') {
context.report({
node,
message: 'Use arrow functions for exports',
});
}
},
};
},
},
},
};
See: Custom ESLint Rules for Determinism
Combine with Type-Driven Development
Declarative constraints map naturally to types:
// Constraint: "Function MUST accept User and return Result<ProcessedUser, Error>"
type ProcessUser = (user: User) => Result<ProcessedUser, ProcessingError>;
// Implementation MUST satisfy this type
const processUser: ProcessUser = (user) => {
// TypeScript enforces the constraint
};
Combine with Verification Sandwich
Declarative constraints enable the verification sandwich pattern:
1. Declare constraints
2. Generate implementation
3. Verify constraints satisfied
4. If not, regenerate with feedback
See: Verification Sandwich Pattern
Combine with Quality Gates
Each quality gate verifies specific constraints:
Constraint: "All functions MUST have tests"
Gate: Test coverage check
Constraint: "All functions MUST have JSDoc"
Gate: ESLint rule
Constraint: "All types MUST be explicit"
Gate: TypeScript strict mode
See: Quality Gates as Information Filters
Measuring Success
Metric 1: Prompt Length
Before (Imperative): 15 lines of step-by-step instructions
After (Declarative): 5 lines of constraints
Reduction: 67%
Metric 2: Maintenance Frequency
Imperative prompts: Updated every 2-3 requirements changes
Declarative prompts: Updated only when constraints change (less frequent)
Reduction: ~50% fewer updates
Metric 3: Success Rate
Imperative: 70% success (fails when steps don't match codebase)
Declarative: 90% success (adapts to codebase structure)
Improvement: 20 percentage points
Metric 4: Verification Time
Imperative: Manual review of implementation approach
Declarative: Automated constraint checking
Reduction: 80% faster verification
Best Practices Summary
✅ Do This
- State what MUST be true, not how to make it true
- Use verifiable constraints (testable, measurable)
- Separate concerns (types, validation, storage, UI)
- Include edge cases as explicit constraints
- Provide examples of satisfying the constraints
- Enable flexibility for LLM to choose best approach
- Verify outcomes, not processes
❌ Avoid This
- Step-by-step instructions unless order is critical
- Vague requirements (“should be good”, “make it better”)
- Implementation details (specific libraries, algorithms) unless required
- Mixing constraints and instructions in same statement
- Over-constraining the solution space
- Under-specifying success criteria
- Assuming codebase structure (“file is in X location”)
Conclusion
Declarative constraints are the single most effective prompt engineering technique for working with LLMs on code.
Key Benefits:
- Robustness: LLM adapts when expected paths fail
- Conciseness: 50-70% shorter prompts
- Flexibility: LLM chooses best implementation
- Verifiability: Automated constraint checking
- Maintainability: Requirements-focused, not implementation-focused
The Shift:
Imperative: "First do X, then Y, then Z"
Declarative: "X, Y, and Z MUST all be true"
Imperative: Prescribes HOW
Declarative: Specifies WHAT
Imperative: Process-oriented
Declarative: Outcome-oriented
Imperative: Fragile
Declarative: Robust
Start Today:
Review your most common prompts. For each imperative instruction, ask:
“Can I rewrite this as a constraint on the outcome rather than a step in the process?”
Your prompts will become shorter, more robust, and more maintainable—while giving better results.
Related Concepts
- Chain-of-Thought Prompting – Structured reasoning before implementation
- Multi-Step Prompt Workflows – Break complex tasks into verified steps
- Few-Shot Prompting with Project Examples – Provide concrete examples alongside declarative constraints
- Layered Prompts Architecture – Structure constraints in composable layers
- Progressive Disclosure Context – Load constraint context progressively
- Explicit Constraints and Non-Goals – Declaring what NOT to do
- Custom ESLint Rules for Determinism – Enforce declarative constraints automatically
- Verification Sandwich Pattern – Generate, verify constraints, iterate
- Quality Gates as Information Filters – Each gate checks specific constraints
References
- Declarative vs Imperative Programming – Background on declarative programming paradigms
- Design by Contract – Related concept: specifying contracts (preconditions, postconditions, invariants)

