When you catch something in the codebase, pick the lightest durable fix. If you jump straight to ESLint each time, you skip the layers that produce the strongest convergence.
Author: James Phoenix | Date: February 2026
Summary
Every time you catch a problem in agent-generated code, you have a choice of prevention layer: types, library primitives, runtime invariants, tests, or lint rules. Each layer has different strength, cost, and durability. The ladder prioritizes the layers that make invalid states impossible (types) over the layers that detect invalid states after the fact (lint). Use the “three strikes” rule: first occurrence is a local fix, second is a type or primitive, third gets a lint rule.
The Ladder
From strongest (top) to weakest (bottom):
Level 1: Types / Signatures ← Make invalid states unrepresentable
Level 2: Library Primitives ← Wrap the dangerous thing once
Level 3: Runtime Invariants ← Guard at system boundaries
Level 4: Tests ← Verify behavior mechanically
Level 5: ESLint / Lint Rules ← Ban structural patterns
Each level is appropriate for different classes of problems. The goal is always to use the highest level that fits.
Level 1: Types and Signatures (Best)
Make invalid states unrepresentable at compile time.
When to use: The problem is a category error, a wrong shape of data, or a missing constraint that the type system can express.
Examples:
// BAD: tenant_id is just a string, easy to forget
function getUser(tenantId: string, userId: string): User { ... }
// GOOD: branded type makes mixing up IDs a compile error
type TenantId = string & { readonly __brand: 'TenantId' };
type UserId = string & { readonly __brand: 'UserId' };
function getUser(tenantId: TenantId, userId: UserId): User { ... }
// BAD: error handling is "remember to try/catch"
async function processPayment(amount: number): Promise<Result> { ... }
// GOOD: Effect encodes errors in the type signature
function processPayment(
amount: PositiveNumber
): Effect.Effect<PaymentResult, PaymentError | NetworkError> { ... }
Strength: Problems caught here never reach runtime. Zero false negatives. Zero runtime cost.
Cost: Requires upfront type design. May need branded types, Effect, or discriminated unions.
Level 2: Library Primitives (Very Good)
Wrap the dangerous operation in a safe primitive. Everyone uses the primitive. Nobody touches the raw operation.
When to use: Multiple places need to do the same dangerous thing (DB queries, API calls, validation). The risk is in the raw operation, not the data shape.
Examples:
// BAD: every query manually adds tenant scoping
const users = await db.query('SELECT * FROM users WHERE tenant_id = ?', [tenantId]);
// GOOD: safe primitive that always scopes
const users = await tenantDb.query(tenantId, 'SELECT * FROM users');
// tenantDb.query internally guarantees tenant_id is always applied
// BAD: every API handler manually validates input
app.post('/users', async (req, res) => {
if (!req.body.email) return res.status(400).send('Missing email');
// ... 20 more manual checks
});
// GOOD: validated endpoint primitive
app.post('/users', validated(CreateUserSchema), async (req, res) => {
// req.body is already validated and typed
});
Strength: Centralizes risk. One correct implementation, used everywhere. Easy to audit.
Cost: Requires designing the primitive. Must be ergonomic enough that agents prefer it over the raw operation.
Level 3: Runtime Invariants (Good)
Guard at system boundaries with runtime checks. Use Zod schemas, Effect Refined types, or explicit assertions.
When to use: The constraint cannot be expressed in types alone (e.g., “this number must be positive,” “this string must be a valid email,” “this array must have at least one element”).
Examples:
// Zod schema at API boundary
const CreateUserInput = z.object({
email: z.string().email(),
age: z.number().int().positive().max(150),
role: z.enum(['admin', 'user', 'viewer']),
});
// Effect refined type for domain logic
const PositiveAmount = Schema.Number.pipe(
Schema.positive({ message: () => 'Amount must be positive' })
);
Strength: Catches constraint violations at runtime boundaries. Clear error messages. Self-documenting.
Cost: Runtime overhead (small). Requires discipline to place guards at every boundary.
Level 4: Tests (Good)
Verify behavior mechanically. Especially effective for property-based tests that cover entire classes of bugs.
When to use: The constraint is behavioral, not structural. “This function must always return sorted output.” “This endpoint must never return more than 100 items.”
Examples:
// Property test: sorting invariant
it.prop([fc.array(fc.integer())], (arr) => {
const sorted = mySort(arr);
for (let i = 1; i < sorted.length; i++) {
expect(sorted[i]).toBeGreaterThanOrEqual(sorted[i - 1]);
}
});
// Regression test: specific bug that was found and fixed
it('does not double-charge when payment webhook retries', async () => {
await processWebhook(duplicateEvent);
await processWebhook(duplicateEvent);
expect(await getChargeCount(userId)).toBe(1);
});
Strength: Catches behavioral bugs. Documents expected behavior. Prevents regressions.
Cost: Tests verify behavior, they do not prevent construction of invalid states. A test catches the bug after it is written.
Level 5: ESLint / Lint Rules (Structural Only)
Ban structural patterns via static analysis. ESLint is the right tool when the constraint is about code structure, not behavior.
When to use: The problem is a structural anti-pattern that agents keep generating. Banned imports. Forbidden function calls. Required patterns.
Good uses for ESLint:
| Rule | What It Prevents |
|---|---|
| No direct DB imports in UI | Layer violations |
| No floating promises | Unhandled async errors |
| Required Effect error channel | Silent failures |
Banned console.log in production |
Debug noise |
Required as const on config objects |
Widened types |
Bad uses for ESLint:
| Rule | Why It’s Wrong |
|---|---|
| “User must be active to bill” | Domain logic, belongs in types/primitives |
| “Query must include tenant_id” | Data constraint, belongs in a primitive |
| “This must be idempotent” | Behavioral, belongs in tests |
| “Response must be under 100ms” | Operational, belongs in monitoring |
Strength: Catches pattern violations before commit. Fast feedback.
Cost: Cannot express semantic or behavioral constraints. Agents will learn to route around lint rules mechanically (reformatting to satisfy the rule without understanding the intent).
The Three Strikes Rule
Do not over-invest in prevention on the first occurrence. Use progressive escalation.
Strike 1: Fix it locally.
Just fix the code. Note it happened.
Strike 2: Add a type or primitive.
The same class of bug appeared twice.
Invest in a structural fix.
Strike 3: Lint it out of existence.
Three times means it's a pattern.
Ban the anti-pattern via lint.
Point the lint error at the blessed primitive.
This prevents premature abstraction. Not every bug needs a lint rule. Most bugs need a local fix and a type improvement.
Rule Proposal Template
Every new lint rule should be documented:
## Rule: no-direct-db-query
### Problem it prevents
Raw database queries bypass tenant scoping, risking data leakage.
### Example bad code
```ts
const users = await db.query('SELECT * FROM users');
Example good code
const users = await tenantDb.query(tenantId, 'SELECT * FROM users');
Why lint (not types/tests)?
The type system cannot distinguish raw DB calls from safe ones.
A test would only catch specific instances.
A lint rule bans the pattern entirely.
How to migrate existing code
Search for db.query( outside of tenantDb.ts. Replace with tenantDb.query(tenantId, ...).
How to measure success
Recurrence rate of tenant scoping bugs should drop to zero within 2 cycles.
---
## Rules Must Pay Rent
Every 2 weeks, review lint rules:
1. **Does this rule still fire?** If it hasn't triggered in 4 weeks, consider removing it.
2. **Does this rule cause noise?** If agents spend time working around it more than fixing real issues, the rule is too broad.
3. **Is there a better layer?** If a type or primitive now exists that makes the rule redundant, delete the rule.
4. **Has the recurrence rate dropped?** If the same class of issue still appears despite the rule, the rule is not effective. Escalate to a higher layer.
Rules that do not earn their keep get deleted. A codebase with 200 lint rules and no convergence is worse than one with 20 rules that actually bite.
---
## Measuring Success
Track recurrence rate per issue class:
```typescript
interface IssueClass {
class: string; // e.g., "tenant-scope-missing"
firstOccurrence: string; // date
occurrences: number; // total times this class appeared
preventionLayer: 'type' | 'primitive' | 'invariant' | 'test' | 'lint';
preventionDate: string; // when prevention was added
recurrenceAfterPrevention: number; // times it reappeared after fix
}
Healthy signal: recurrenceAfterPrevention drops to zero after adding prevention.
Unhealthy signal: recurrenceAfterPrevention stays nonzero. The prevention layer is wrong or too weak. Escalate to a higher layer.
Key Insight
ESLint is not a dumping ground for domain rules. It is the last resort for structural patterns that survived types, primitives, invariants, and tests. Every lint rule should point at a blessed alternative. Every rule must earn its keep.
Related
- Custom ESLint Rules for Determinism – How to build effective lint rules when they are the right layer
- Constraint-First Development – Define constraints first, code second
- Early Linting Prevents Ratcheting – Why early constraints prevent debt accumulation
- Invariants in LLM Code Generation – Formal invariant theory
- Making States Illegal – Type-level prevention (Level 1 of the ladder)
- The Verification Ladder – Complementary escalation from types to formal proofs
- Synthetic Loss Functions – Each ladder level reduces a different loss term
- Online Learning via Constraints – The observe-constrain loop that feeds this ladder

