Declarative Constraints Over Imperative Instructions

James Phoenix
James Phoenix

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:

Udemy Bestseller

Learn Prompt Engineering

My O'Reilly book adapted for hands-on learning. Build production-ready prompts with practical exercises.

4.5/5 rating
306,000+ learners
View Course
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

  1. 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)
    
  2. 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
    
  3. 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
};

See: Type-Driven Development

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

  1. State what MUST be true, not how to make it true
  2. Use verifiable constraints (testable, measurable)
  3. Separate concerns (types, validation, storage, UI)
  4. Include edge cases as explicit constraints
  5. Provide examples of satisfying the constraints
  6. Enable flexibility for LLM to choose best approach
  7. Verify outcomes, not processes

❌ Avoid This

  1. Step-by-step instructions unless order is critical
  2. Vague requirements (“should be good”, “make it better”)
  3. Implementation details (specific libraries, algorithms) unless required
  4. Mixing constraints and instructions in same statement
  5. Over-constraining the solution space
  6. Under-specifying success criteria
  7. 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:

  1. Robustness: LLM adapts when expected paths fail
  2. Conciseness: 50-70% shorter prompts
  3. Flexibility: LLM chooses best implementation
  4. Verifiability: Automated constraint checking
  5. 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

References

Topics
ConstraintsDeclarativeError HandlingFlexibilityImperativePrompt DesignPrompt EngineeringRequirementsRobustnessVerification

More Insights

Cover Image for Thought Leaders

Thought Leaders

People to follow for compound engineering, context engineering, and AI agent development.

James Phoenix
James Phoenix
Cover Image for Systems Thinking & Observability

Systems Thinking & Observability

Software should be treated as a measurable dynamical system, not as a collection of features.

James Phoenix
James Phoenix