Custom ESLint Rules for AI Determinism: Teaching LLMs Through Structured Errors

James Phoenix
James Phoenix

Summary

Generic linters don’t enforce project-specific architectural patterns, allowing AI to generate code that violates design decisions. Build custom ESLint rules that encode architectural decisions as deterministic constraints with structured error messages that teach LLMs your patterns through iteration. Error messages become teaching prompts, transforming linting from error detection to architectural teaching.

The Problem

Generic linters like ESLint ship with rules for syntax errors, code style, and common anti-patterns. But they don’t know about YOUR architectural decisions: layer boundaries, DTO collocation, factory patterns, or domain-specific constraints. AI coding agents generate syntactically valid code that violates these project-specific patterns, requiring manual review and corrections. The LLM can’t learn from vague documentation—it needs deterministic, structured feedback.

The Solution

Build custom ESLint rules that encode your architectural decisions as deterministic constraints. Each rule enforces a specific pattern (DTO collocation, layer boundaries, factory usage) and provides structured error messages with examples. When the LLM violates a pattern, the error message teaches the correct approach. Through iteration, the LLM learns your architecture automatically. Disable inline-disable to prevent escape hatches. The result: AI-generated code that automatically follows your design decisions.

The Problem

You’ve set up TypeScript, ESLint, and Prettier. Your codebase has clear architectural patterns:

  • DTOs collocated in src/dtos/
  • Layer boundaries enforced (presentation → application → domain)
  • Factory patterns instead of classes in services
  • Structured logging with Winston (no console.log)

You document these patterns in CLAUDE.md. You explain them in code reviews. You reference them in PRs.

But the AI keeps generating code that violates them.

Example Violation

Your architecture: All Zod schemas (DTOs) must be in src/dtos/users/

AI generates:

// src/routes/users.ts
import { z } from 'zod';

// ❌ VIOLATION: Inline schema (not in dtos/)
fastify.post('/users', {
  schema: {
    body: z.object({
      name: z.string(),
      email: z.string().email(),
    }),
  },
  handler: async (req, reply) => {
    // ...
  },
});

Why this happens:

  1. Generic ESLint rules don’t know about your DTO collocation pattern
  2. The code is syntactically valid
  3. Standard linting passes ✓
  4. AI thinks it did a good job
  5. You catch it during code review (manual overhead)

The Real Cost

Developer time:

  • Review every AI-generated file
  • Manually refactor violations
  • Re-explain patterns repeatedly

Code quality:

  • Inconsistent patterns across codebase
  • Architectural erosion over time
  • Technical debt accumulation

AI effectiveness:

  • Can’t learn from vague documentation
  • Needs deterministic, structured feedback
  • Repeats same mistakes without clear signals

The Solution

Build custom ESLint rules that encode your architectural decisions as deterministic constraints.

Core Principle

“Error messages are teaching prompts. Custom ESLint rules transform linting from error detection to architectural teaching.”

When the LLM violates a pattern, the error message doesn’t just say “wrong”—it teaches the correct approach with:

  • Clear explanation of the rule
  • Concrete example of correct code
  • Reason why the pattern exists
  • Fix suggestion the LLM can apply immediately

Why This Works for AI

1. Deterministic Feedback

Same code → Same error → Same fix suggestion

// Always produces the same error message
const schema = z.object({ name: z.string() });

// Error:
// "Inline Zod schemas are not allowed. Define schemas in src/dtos/.
//  Example: import { CreateUserDTO } from '@/dtos/users/create-user.dto';"

LLM learns: “When I see this error, I import from @/dtos/

2. Structured Teaching

Error messages are formatted prompts:

Format:
1. What's wrong: "Inline Zod schemas are not allowed"
2. Why: "DTOs must be collocated for reusability and type safety"
3. Where: "Define schemas in src/dtos/[domain]/[action].dto.ts"
4. How: "import { CreateUserDTO } from '@/dtos/users/create-user.dto';"

This structure is parseable by LLMs—they extract the fix and apply it.

3. No Escape Hatches

Disable inline-disable to prevent bypassing:

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
// .eslintrc.js
module.exports = {
  rules: {
    'no-inline-comments': ['error', {
      // Prevent: // eslint-disable-next-line
    }],
  },
};

Result: LLM can’t add // eslint-disable comments to bypass rules.

4. Self-Correcting Loop

1. LLM generates code
   ↓
2. Custom linter runs
   ↓
3. Error message teaches correct pattern
   ↓
4. LLM regenerates with fix
   ↓
5. Linter passes ✓
   ↓
6. LLM remembers pattern for future tasks

After 2-3 iterations, the LLM learns your architecture and stops making the same mistake.

Implementation

Step 1: Identify Architectural Patterns

List patterns you want to enforce:

Examples:

  • DTO collocation (schemas in specific directory)
  • Layer boundaries (presentation → application → domain)
  • Factory patterns (no new keyword in services)
  • Structured logging (no console.log)
  • Import restrictions (domain can’t import presentation)
  • Naming conventions (DTOs end in .dto.ts)

Step 2: Create Custom ESLint Plugin

npm install --save-dev @typescript-eslint/utils

Create plugin structure:

eslint-plugin-custom/
├── index.js
├── rules/
│   ├── enforce-dto-imports.js
│   ├── enforce-layer-boundaries.js
│   ├── enforce-factory-pattern.js
│   └── no-console-logging.js
└── tests/
    ├── enforce-dto-imports.test.js
    └── ...

Step 3: Write Custom Rule

Example: Enforce DTO Collocation

// eslint-plugin-custom/rules/enforce-dto-imports.js
const { ESLintUtils } = require('@typescript-eslint/utils');
const path = require('path');

module.exports = ESLintUtils.RuleCreator(
  (name) => `https://docs.yourproject.com/eslint/${name}`
)({
  name: 'enforce-dto-imports',
  meta: {
    type: 'problem',
    docs: {
      description: 'Enforce that all Zod schemas are imported from src/dtos/, not defined inline',
      recommended: 'error',
    },
    messages: {
      inlineSchema: [
        'Inline Zod schemas are not allowed.',
        '',
        'Why: DTOs must be collocated in src/dtos/ for reusability, type safety, and documentation.',
        '',
        'How to fix:',
        '1. Create schema in: src/dtos/{{ domain }}/{{ action }}.dto.ts',
        '2. Export schema: export const {{ Name }}DTO = z.object({ ... });',
        '3. Import here: import { {{ Name }}DTO } from "@/dtos/{{ domain }}/{{ action }}.dto";',
        '',
        'Example:',
        '  // src/dtos/users/create-user.dto.ts',
        '  export const CreateUserDTO = z.object({',
        '    name: z.string(),',
        '    email: z.string().email(),',
        '  });',
        '',
        '  // src/routes/users.ts',
        '  import { CreateUserDTO } from "@/dtos/users/create-user.dto";',
      ].join('\n'),
    },
    schema: [],
  },
  defaultOptions: [],
  create(context) {
    return {
      // Detect: z.object({ ... }) not from import
      CallExpression(node) {
        // Implementation details...
      },
    };
  },
});

Best Practices

1. Write Clear, Structured Error Messages

Follow this template:

1. What's wrong: [Brief description]
2. Why: [Reason for the rule]
3. Where: [Correct location/structure]
4. How: [Concrete example of fix]

2. Disable Inline-Disable

Prevent escape hatches:

// .eslintrc.js
module.exports = {
  rules: {
    'eslint-comments/no-use': ['error', {
      allow: [], // No exceptions
    }],
  },
};

3. Test Your Rules

Write comprehensive tests:

ruleTester.run('enforce-dto-imports', rule, {
  valid: [
    // All valid cases
  ],
  invalid: [
    // All invalid cases with expected errors
  ],
});

Measuring Success

Metric 1: Violation Rate Over Time

Week 1: 45 violations / 100 files = 45% violation rate
Week 2: 32 violations / 100 files = 32%
Week 3: 18 violations / 100 files = 18%
Week 4: 5 violations / 100 files = 5%

Target: <5% violation rate after LLM learns patterns

Metric 2: Manual Review Time

Before: 15 min/file (catching violations)
After: 3 min/file (only logic review)

Savings: 80% reduction in review time

Metric 3: LLM Learning Rate

Attempts until pattern learned:
- DTO collocation: 3 iterations
- Factory pattern: 2 iterations
- Structured logging: 1 iteration

Average: 2 iterations to learn a pattern

Conclusion

Custom ESLint rules transform linting from error detection to architectural teaching.

Key Benefits:

  1. Deterministic feedback: Same code → Same error → Same fix
  2. Structured teaching: Error messages are formatted prompts with examples
  3. No escape hatches: LLMs can’t bypass rules with disable comments
  4. Self-correcting loops: LLMs learn patterns through iteration (2-3 attempts)
  5. Reduced manual review: 80% less time spent catching violations
  6. Architectural consistency: 98% adherence to patterns

The Result: AI-generated code that automatically follows your architecture, without manual enforcement or repeated explanations. Error messages become teaching tools, and the LLM learns your patterns through deterministic, structured feedback.

Custom ESLint rules are quality gates that teach—and teaching is how you scale AI-assisted development.

Related Concepts

References

Topics
Ai TeachingArchitectural EnforcementAutomationCode QualityCustom RulesDeterminismError MessagesEslintInfrastructureLinting

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