Opinionated Rules for implementing multi-layer data validation in TypeScript services backed by PostgreSQL.
You know the drill: users submit corrupted data, your API accepts it, your database constrains explode, and now you're debugging production at 2 AM wondering which layer failed. Meanwhile, your "validation" is scattered across frontend forms, API middleware, and random database constraints that nobody documents.
Most TypeScript applications treat validation like an afterthought—sprinkle some Zod here, add a database constraint there, maybe throw in some frontend validation if you're feeling fancy. The result? Validation logic drift, debugging nightmares, and production failures that could have been caught at development time.
Your current workflow probably looks like this:
Here's what's actually happening: You're treating validation as infrastructure instead of first-class business logic. Your validation rules should be declarative, testable, and synchronized across every layer of your stack.
These Cursor Rules implement a defense-in-depth validation system that treats data validation as mission-critical business logic. Every rule exists in your TypeScript code, database constraints, and automated tests—synchronized and version-controlled.
The system enforces earliest possible rejection: malformed data gets stopped at the first possible layer, with consistent error formatting and comprehensive logging throughout your entire stack.
Key breakthrough: Instead of maintaining separate validation logic, you define rules once and automatically generate database constraints, TypeScript types, API validation, and test cases from the same source.
// API layer - maybe validates email format
if (!email.includes('@')) throw new Error('Invalid email');
// Database - different email validation
ALTER TABLE users ADD CONSTRAINT email_check
CHECK (email LIKE '%@%.%');
// Frontend - yet another validation approach
<input type="email" required />
// schema.ts - Single source of truth
export const UserSchema = z.object({
email: z.string().email().max(254),
age: z.number().int().min(0).max(120),
phone: z.string().regex(/^\+[0-9]{10,}$/)
});
// Automatically generates:
// - TypeScript types
// - Database constraints
// - API validation
// - Test cases
// - Documentation
Stop Context Switching: Define validation rules once instead of maintaining 3-5 different implementations across your stack.
Catch Bugs Earlier: Automated constraint generation means database-level validation catches what your application layer misses—before production.
Eliminate Debug Time: Structured error responses with validation rule categories (DATA_TYPE_CHECK, UNIQUENESS_CHECK, etc.) tell you exactly what failed and where.
Test Confidence: Robot Framework integration generates database-level tests automatically, giving you end-to-end validation coverage without manual test writing.
Traditional approach: Write validation in API, update database schema, modify frontend, write tests, update documentation—5 separate tasks across different files and technologies.
With these rules:
// Add to schema.ts
export const ProductSchema = z.object({
name: z.string().min(1).max(100),
price: z.number().positive(),
sku: z.string().regex(/^[A-Z]{2}-\d{6}$/) // New field
});
Automatically generates:
CHECK (sku ~ '^[A-Z]{2}-[0-9]{6}$')Traditional approach: Parse database logs, check multiple validation layers, reproduce issue locally.
With structured validation:
{
"requestId": "req_123",
"rule": "FORMAT_CHECK",
"path": "user.phone",
"code": "INVALID_PATTERN",
"message": "Phone must match international format"
}
Immediate insight into which validation rule failed, with categorized logging that preserves audit trails while protecting PII.
Instead of hoping your migration works:
// Pre-migration validation
node scripts/validate-csv.ts legacy-users.csv
// Streams through Zod validation
// Logs rejects, imports only valid rows
src/
├── entities/
│ └── user/
│ ├── schema.ts # Zod schemas + TypeScript types
│ ├── validation.ts # Higher-order validators
│ └── service.ts # Business logic
├── lib/
│ └── error.ts # Centralized error handling
tests/
├── unit/ # Jest unit tests
├── integration/ # API integration tests
└── db/ # Robot Framework DB tests
// entities/user/schema.ts
import { z } from 'zod';
export const CreateUserSchema = z.object({
email: z.string().email().max(254),
age: z.number().int().min(0).max(120),
phone: z.string().regex(/^\+[0-9]{10,}$/),
isActive: z.boolean().default(true)
});
export type CreateUser = z.infer<typeof CreateUserSchema>;
-- Auto-generated from Zod schema
ALTER TABLE users ADD CONSTRAINT email_length_check
CHECK (char_length(email) <= 254);
ALTER TABLE users ADD CONSTRAINT age_range_check
CHECK (age BETWEEN 0 AND 120);
ALTER TABLE users ADD CONSTRAINT phone_format_check
CHECK (phone ~ '^\+[0-9]{10,}$');
# tests/db/user_validation.robot
*** Test Cases ***
TC-RANGE-001 Age cannot exceed maximum
[Tags] range_check
Connect To Database PostgreSQL ${DB_URI}
${result}= Run Keyword And Expect Error *
... Execute SQL String INSERT INTO users(age) VALUES(150)
Should Contain ${result} age_range_check
// Centralized validation error mapping
export function mapPrismaError(error: PrismaClientKnownRequestError) {
const errorMap = {
'P2002': { code: 'UNIQUENESS_VIOLATION', status: 422 },
'P2003': { code: 'FOREIGN_KEY_VIOLATION', status: 422 },
'P2025': { code: 'RECORD_NOT_FOUND', status: 404 }
};
return errorMap[error.code] || { code: 'UNKNOWN_ERROR', status: 500 };
}
Development Speed: Reduce validation-related development time by 60-70% through automated constraint generation and unified rule definition.
Bug Reduction: Catch validation failures at compile-time and database-level instead of production runtime. Typical reduction of 40-50% in data-related production issues.
Debug Efficiency: Structured error responses with rule categorization reduce debugging time from hours to minutes.
Test Coverage: Automated Robot Framework test generation achieves 95%+ validation rule coverage without manual test writing.
Audit Compliance: Version-controlled validation rules with comprehensive logging provide complete audit trails for data integrity requirements.
Team Alignment: Single source of truth for validation rules eliminates miscommunication between frontend, backend, and database teams.
The system pays for itself within the first sprint by eliminating the validation debugging cycles that currently consume your development time. Your data integrity becomes predictable, your debugging becomes surgical, and your team focuses on building features instead of chasing validation bugs.
You are an expert in: Node.js 20+, TypeScript 5, PostgreSQL 15, Prisma ORM, Zod, Robot Framework (Database Library), Docker, GitHub Actions.
Key Principles
- Treat *validation* as first-class business logic; never rely on a single layer.
- Push the **earliest possible rejection** of bad data (front-end ➜ API ➜ DB).
- Prefer *declarative* constraints (SQL CHECKs, UNIQUE, FK) over imperative code.
- Every rule must be **testable**, version-controlled and documented.
- Fail fast with actionable errors (HTTP 422 with JSON body, SQLSTATE details).
- Keep validation logic **idempotent** and side-effect free; mutations happen after validation passes.
- Logs are facts; never swallow or mutate validation errors.
TypeScript
- Enable `"strict": true` and `"exactOptionalPropertyTypes": true`; forbid `any`.
- Define DTOs with `interface`; derive Zod schemas from the same source via `zodDto()` helper to avoid drift.
- Name boolean flags with prefixes `is/has/should`, e.g. `isArchived`.
- File layout per entity:
entity/
├─ index.ts // public API
├─ schema.ts // Zod + TS types
├─ service.ts // business rules
└─ validation.ts // higher-order validators/reuse
- Use top-level `async function` over arrow for reusability in tests.
- Prefer `const` and immutability; never reassign function params.
Error Handling & Validation
- Categorise rules (⟶ use enum ValidationRule):
• DATA_TYPE_CHECK – type/length – Zod `.string().max(20)`
• RANGE_CHECK – min/max numeric dates – SQL `CHECK (age BETWEEN 0 AND 120)`
• FORMAT_CHECK – regex patterns – `z.string().regex(/^\+[0-9]{10,}$/)`
• PRESENCE_CHECK – NOT NULL + Zod `.nonempty()`
• UNIQUENESS_CHECK – SQL `UNIQUE` + upsert guard
• CONSISTENCY_CHECK – cross-field logic – Zod `.refine((d)=>d.start<d.end)`
• REFERENTIAL_CHECK – SQL `FOREIGN KEY` ON DELETE/UPDATE rules
- Pre-validate request body with Zod; return `422` with array of `{path,code,message}`.
- Propagate only *one* consolidated error payload per request.
- Wrap DB calls in `try/catch` and re-map `Prisma.PrismaClientKnownRequestError` codes (`P2002` ⇒ Duplicate).
- Log shape: `{time, requestId, rule, severity, meta}`. Never log PII raw values—hash or mask.
PostgreSQL / Prisma
- Always mirror Zod rules in database constraints:
• `.min(1)` ⇔ `CHECK (char_length(name) > 0)`
• email uniqueness ⇔ `UNIQUE (email)`
- Prefer **partial indexes** for filtered uniqueness, e.g.
`CREATE UNIQUE INDEX ux_user_email_active ON users(email) WHERE deleted_at IS NULL;`
- Use `EXCLUDE USING gist` for complex constraints (date range overlap).
- Store enums in the DB (`CREATE TYPE status_enum AS ENUM (...)`) and map via Prisma enum.
- Apply migrations via `prisma migrate deploy` in CI; block merge on migration diff.
- Wrap multi-step validations in DB transactions with `REPEATABLE READ` isolation.
Robot Framework (Database Library)
- Place DB tests under `tests/db/*.robot`; name with `TC-<rule-id>-<short>`.
- Template pattern:
```robotframework
*** Test Cases ***
TC-DATA_TYPE-001 Verify age cannot be negative
[Tags] data_type_check negative
Connect To Database PostgreSQL ${DB_URI}
${status}= Execute Sql INSERT INTO person(age) VALUES(-1)
Should Contain ${status} ERROR
```
- Tag every test with the rule category to enable selective runs: `robot -i uniqueness_check`.
Testing
- Unit: Jest + ts-jest – cover every validator (>95% branch).
- Integration: Prisma test DB spun via `docker-compose up -d db-test`.
- Contract: Sync Zod schemas to Postman collections for client validation.
- CI: GitHub Actions matrix (node 18, 20) ➜ `npm run test && robot tests/db`.
Performance
- Run heavy regex checks in application tier, not DB.
- Index columns referenced by RANGE/FORMAT lookup to avoid seq scans.
- Monitor constraint violation rate; sudden spikes imply upstream issues.
Security
- Never concatenate SQL – always parameterise (`$1,$2`).
- Restrict `SELECT` on sensitive tables; validation users get only required columns.
- Store validation error logs in write-only store; redact values ≥ GDPR-sensitive.
Documentation
- Each rule lives in `/docs/validation/<entity>/<rule>.md` with:
Rule-ID | Layer | Description | SQL | Zod | Tests | Last-Review.
- Review cadence: quarterly or upon major schema change.
Common Pitfalls & Guards
- PITFALL: Boolean NOT NULL default false but Zod `.boolean()` allows undefined ⟶ use `.default(false)`.
- PITFALL: Timestamp input mismatch timezone ⟶ always store UTC, validate with `.refine(isValidTZ)`.
- PITFALL: Soft-delete breaks uniqueness ⟶ use partial index as above.
Cheat-Sheet Examples
- Enforcing phone format both layers:
TypeScript: `z.string().regex(/^[0-9]{3}-[0-9]{3}-[0-9]{4}$/)`
SQL: `CHECK (phone ~ '^[0-9]{3}-[0-9]{3}-[0-9]{4}$')`
- Data migration validation:
1. Extract legacy rows ➜ CSV.
2. Run `node scripts/validate-csv.ts` (stream Zod validation).
3. Import only valid rows; log rejects.
Directory Template
db-validation-template/
├─ src/
│ ├─ entities/
│ │ └─ user/
│ │ ├─ schema.ts
│ │ ├─ validation.ts
│ │ └─ service.ts
│ ├─ lib/
│ │ └─ error.ts
│ └─ index.ts
├─ tests/
│ ├─ unit/
│ ├─ integration/
│ └─ db/
└─ docs/
└─ validation/
Follow these rules rigorously to guarantee consistent, audited and performant data validation across your stack.