@martinmilo/verve
Version:
TypeScript domain modeling library with field-level authorization, business rule validation, and context-aware access control
1,290 lines (993 loc) • 39.2 kB
Markdown
# Verve
A TypeScript domain modeling library that helps you build secure business layers with **field-level authorization**, **business rule validation**, and **context-aware access control**.
## Why Verve?
Building robust domain models means more than just defining fields. You need to:
- ✅ **Enforce business rules** at the field level
- ✅ **Control who can read/write** specific fields based on context
- ✅ **Authorize method calls** based on user roles and permissions
- ✅ **Validate business invariants** automatically
- ✅ **Prevent unauthorized access** to sensitive data
Verve makes this **declarative** and **type-safe**.
## Installation
```bash
npm install verve
```
## Use Cases
- **🏦 Financial Applications** - Secure account data, transaction authorization
- **🏥 Healthcare Systems** - HIPAA-compliant patient data access
- **👥 Multi-tenant SaaS** - User isolation and role-based permissions
- **📊 Admin Dashboards** - Role-based field visibility and editing
- **🔐 Enterprise Apps** - Complex authorization workflows
## Philosophy of Verve
Verve is built on four core principles that guide every design decision:
### 🚨 **No Silent Errors**
We believe errors should be **loud and immediate**. When something goes wrong, Verve throws exceptions as soon as possible rather than allowing invalid states to propagate through your application or worse, corrupt your data. This means:
- **Field access validation** - Accessing uninitialized fields throws immediately
- **Authorization failures** - Permission violations throw clear, actionable errors
- **Business rule violations** - Validation failures are caught at the point of assignment or can be lazily evaluated at any point
- **Type safety** - Invalid operations are prevented at compile-time when possible
```typescript
const user = User.from({ name: 'John' }) // No email provided
user.name // ✅ 'John' - field is initialized
user.email // ❌ Throws - field is uninitialized
```
### 🎯 **Centralized Business Rules**
Rather than spreading business logic throughout your application layers, Verve keeps domain rules **transparent and in one place** - your model definitions. This means:
- **Field-level validation** - Business rule(s) applicable on single field
- **Authorization logic** - Access control is declared alongside field definitions
- **Business invariants** - Cross-field validation ensures domain consistency
- **Single source of truth** - No hunting through controllers, services, and middleware
```typescript
({
// Business rule: Salary constraints based on level
salary: number().validate([
(value, model) => {
if (model.level === 'junior') return value <= 80_000
if (model.level === 'senior') return value >= 100_000
return value > 0
}
])
// Authorization: Only HR can read salary
.readable((context) => context.auth.department === 'HR'),
level: text()
})
class Employee extends Model.Typed<'Employee'>() {}
```
### 🔐 **Privacy is King**
Data privacy and security are not afterthoughts - they're **fundamental to how Verve works**. Every field access is checked and validated:
- **Field-level authorization** - Control exactly who can read/write each field
- **Context-aware permissions** - Access rules adapt based on user roles and relationships
- **Secure by default** - Sensitive fields can be hidden unless explicitly authorized
- **Method authorization** - Business operations require proper permissions
```typescript
({
// Public information
name: text(),
// Sensitive data - never readable by default
ssn: text().readable(false),
// Context-sensitive access
salary: number().readable((context, employee) =>
context.auth.id === employee.id || context.auth.role === Role.HR
)
})
class Employee extends Model.Typed<'Employee'>() {}
```
### ✨ **Safe Partial Models**
Verve provides **crystal-clear semantics** for field states, eliminating confusion between `null` and `undefined`:
- **`undefined`** - Field is uninitialized and will throw on access
- **`null`** - Field is intentionally empty (nullable fields only)
This enables safe partial model hydration where you can work with incomplete data while maintaining strict safety guarantees:
```typescript
// Partial hydration from API - only some fields provided
const user = User.from({ id: '123', name: 'John' })
user.name // ✅ 'John' - field is initialized
user.email // ❌ Throws - field is uninitialized
user.isActive // ❌ Throws - field is uninitialized
// Check field state safely via model method
user.hasPresent('email') // false - field 'email' is uninitialized
user.hasPresent('name') // true - field 'name' has value
// Validate model fields against their validators safely
const errors = user.validate() // VerveErrorList
if (errors.isEmpty()) {
// You're good to go
}
// Nullable vs uninitialized
user.set({ bio: null }) // ✅ Explicitly set to null (if nullable)
user.set({ email: null }) // ❌ Throws since field is not nullable (IDE should also complain)
```
These principles ensure your domain models are **secure, predictable, and maintainable** while providing excellent developer experience through clear error messages and type safety.
## Practical Example
```typescript
import { Model, model, can, text, id, number, bool, option } from 'verve'
enum Role {
USER = 'user',
ADMIN = 'admin'
}
({
id: id(),
email: text().validate.only([(value) => value.includes('@')]),
// Only readable by the user themselves or admins
ssn: text().readable((context, user) =>
context.auth.id === user.id || context.auth.role === Role.ADMIN
),
// Only writable by admins
role: option(Role).writable((context) =>
context.auth.role === Role.ADMIN
),
// Business rule: age must be over 18
age: number().validate.only([(value) => value >= 18]),
// Never readable (passwords should never be exposed to anyone)
password: text().readable(false),
isActive: bool().default(true)
})
class User extends Model.Typed<'User'>() {
// Only the user themselves can generate their credit report
((context, user) => context.auth.id === user.id)
generateCreditReport() {
// Logic to generate credit report…
}
// Users can close their own account, admins can close any account
((context, user) =>
context.auth.id === user.id || context.auth.role === Role.ADMIN
)
closeAccount(reason: string) {
// Logic to archive and close account, notify user…
}
}
```
## Quick Start
Let's build your first Verve model step by step:
### 1. Define a Basic Model
```typescript
import { Model, model, text, id, number, bool, date } from 'verve'
({
id: id(),
name: text(),
email: text(),
age: number().nullable,
isActive: bool().default(true),
createdAt: date().generate(() => new Date())
})
class User extends Model.Typed<any>() {}
```
### 2. Update TS Configuration
Configure your `tsconfig.json` to tell TypeScript where to find the types that will be generated. **You only need to do this once** - no need to repeat this step for future models:
```json
{
"compilerOptions": {
// ... your existing options
},
"include": [
"src/**/*",
".verve/models.d.ts"
]
}
```
### 3. Generate Types
Run the Verve CLI to generate TypeScript types from your model schema:
```bash
npx verve
```
This automatically updates your model class:
```typescript
// Before: Model.Typed<any>()
class User extends Model.Typed<any>() {}
// After: Model.Typed<'User'>()
class User extends Model.Typed<'User'>() {}
```
### 4. Keep Types in Sync
Verve generates types from your actual schema definitions, but you should ensure these stay up to date. Consider adding a pre-commit hook or CI step:
```bash
# Add to package.json scripts
"scripts": {
"pre-commit": "npx verve && git add ."
}
```
This way your IDE will always have accurate type information and won't complain about missing or outdated types.
## Core Features
### 🔐 Field-Level Authorization
Control exactly who can read or write each field:
```typescript
({
// Public field - everyone can read/write
name: text(),
// Read-only for everyone except admins
salary: number()
.readable(true)
.writable((context) => context.auth.role === Role.ADMIN),
// Only readable by the owner or HR
personalNotes: text().readable((context, employee) =>
context.auth.id === employee.id ||
context.auth.department === 'HR'
),
// Never readable (sensitive data)
encryptedData: text().readable(false)
})
class Employee extends Model.Typed<'Employee'>() {}
```
### ✅ Business Rule Validation
Enforce business rules and domain invariants automatically:
```typescript
({
// Single-field business rule validation
age: number().validate([
(value) => value >= 18 && value <= 120
]),
// Single-field validation with multiple validators
email: text().validate([
(value) => value.includes('@'),
(value) => value.endsWith('placeholder.com')
]),
// Cross-field validation (using other model fields)
salary: number().validate([
(value, model) => {
// Junior employees can't earn more than $80k
if (model.level === 'junior') return value <= 80_000;
// Senior employees must earn at least $100k
if (model.level === 'senior') return value >= 100_000;
return value > 0;
}
]),
level: option(['junior', 'mid', 'senior']),
// Context-aware validation (using current user context)
confidentialRating: number().validate([
(value, model, context) => {
// Only HR can set ratings above 8
if (value > 8) return context.auth.department === 'HR';
// Managers can set ratings 1-8
if (value >= 1) return context.auth.role === 'manager' || context.auth.department === 'HR';
return false;
}
])
})
class Employee extends Model.Typed<'Employee'>() {}
```
Single-field validation handles basic constraints, cross-field validation enforces complex business rules between fields, and context-aware validation adapts rules based on who's making the change.
```typescript
({
// Business invariant: discount cannot exceed price
discount: number().validate([
(value, model) => value <= model.price,
(value) => value >= 0
]),
price: number().validate([
(value) => value > 0,
(value) => Number.isFinite(value)
])
})
class Product extends Model.Typed<'Product'>() {}
```
Business invariants ensure your domain rules are always enforced, automatically **catching violations before they can corrupt your data**.
### 🎯 Method Authorization
Secure your domain methods:
```typescript
class BankAccount extends Model.Typed<'BankAccount'>() {
// Only account owner can check balance
((context, account) => context.auth.id === account.ownerId)
getBalance() {
return this.balance;
}
// Only admins can freeze accounts
((context) => context.auth.role === Role.ADMIN)
freeze() {
this.isActive = false;
}
// Complex authorization logic
((context, account) => {
const isOwner = context.auth.id === account.ownerId;
const isManager = context.auth.role === Role.MANAGER;
const isSameBank = context.auth.bankId === account.bankId;
return isOwner || (isManager && isSameBank);
})
transfer(amount: number, targetAccount: string) {
// Transfer logic
}
}
```
### 🏗️ Context-Aware Security
Set context for secure, request-scoped authorization:
#### Node.js Server
```typescript
import { Context } from 'verve'
import express from 'express'
// Set up proper request isolation for Node.js apps
Context.useAsyncLocalStorage()
const app = express()
// Middleware to extract user context from request
app.use((req, res, next) => {
const userContext = {
auth: {
id: req.user?.id,
role: req.user?.role,
department: req.user?.department
}
}
// Context automatically scoped to this request
Context.run(userContext, () => {
next()
})
})
// Your route handlers
app.get('/profile/:userId', (req, res) => {
// Context automatically available, no context bleeding between requests
const user = User.make({ id: req.params.userId, email: 'john.com' })
try {
const sensitiveData = user.ssn // ❌ Unauthorized access
res.json({ ssn: sensitiveData })
} catch (error) {
res.status(403).json({ error: 'Unauthorized' })
}
res.json({ email: user.email }) // ✅ Public field
})
```
#### Browser Environment
Browser automatically uses global storage, no additional setup needed. If you want to be explicit:
```typescript
import { Context } from 'verve'
// Set context when user logs in
function handleLogin(user) {
Context.set({
auth: {
id: user.id,
role: user.role,
permissions: user.permissions
}
})
}
// Clear context when user logs out
function handleLogout() {
Context.reset()
}
// Context persists across your SPA until reset
const user = User.make({ email: 'john.com' })
console.log(user.email) // Uses current logged-in user context
```
#### Any Node.js Framework
```typescript
// Set up once at app startup
Context.useAsyncLocalStorage()
// Works with Fastify, Koa, NestJS, or any Node.js app
Context.run(userContext, () => {
// All Verve operations use this context
const model = MyModel.from(data)
model.someAuthorizedMethod()
});
```
**Why different adapters?**
- **Node.js servers**: Need `AsyncLocalStorage` to prevent context leaking between concurrent requests
- **Browsers/SPAs**: Global storage works fine since there's only one user per browser tab
## Field Types
Verve supports rich field types with default/generated values and validation:
```typescript
({
// Example of lazy validator
name: text().validate.lazy([(v) => v.length < 20]),
// By default all validators are eager
email: text().validate([(v) => v.includes('@')]),
// Numeric field with business rules
price: number().validate([(v) => v > 0]),
// Boolean field with defaults
isActive: bool().default(true),
// Date field with automatically generated date
createdAt: date().generate(() => new Date()),
// Enum field
status: option(Status).default(Status.PENDING),
// Array field
tags: list<string>().default([]),
// Object field
metadata: record<{ theme: string; lang: string }>()
.default({ theme: 'light', lang: 'en' })
})
class MyModel extends Model.Typed<'MyModel'>() {}
```
### Basic Types
```typescript
// ID field - for unique identifiers (usually strings)
id()
// Text field - for strings
text()
// Number field - for integers and floats
number()
// Boolean field - for true/false values
bool()
// Date field - for Date objects
date()
```
### Complex Types
```typescript
// Option field - for enums and constrained values
option(['draft', 'published', 'archived'])
option(StatusEnum)
// List field - for arrays
list<string>() // Array of strings
list<User>() // Array of User models
// Record field - for objects
record<{ name: string; age: number }>()
```
### Field Modifiers
Each field type supports different modifiers based on its capabilities:
#### ID Field
```typescript
id()
.generate(() => crypto.randomUUID()) // Generate ID when creating new models
.validate([(id) => id.length > 0]) // Validate ID format
// Note: ID fields are only writable when model.isNew() - automatically enforced
```
#### Text Field
```typescript
text()
.nullable // Allow null values
.default('Hello World') // Static default value
.generate(() => crypto.randomUUID()) // Generate value on model creation
.validate([(value) => value.length > 3]) // Validation rules
.validate.only([(value) => value.includes('@')]) // Ignore global validators, use only these
.validate.lazy([(value) => value.length < 1000]) // Lazy validation (not run immediately)
.readable((context, model) => context.auth.role === 'admin') // Read authorization
.writable((context, model) => context.auth.id === model.ownerId) // Write authorization
```
#### Number Field
```typescript
number()
.nullable // Allow null values
.default(0) // Static default value
.generate(() => Math.random()) // Dynamic generated value
.validate([(value) => value > 0]) // Validation rules
.readable((context, model) => context.auth.role === 'admin')
.writable((context, model) => context.auth.department === 'finance')
```
#### Boolean Field
```typescript
bool()
.nullable // Allow null values
.default(true) // Static default value
.validate([(value) => value === true]) // Validation rules
.readable((context, model) => context.auth.role === 'admin')
.writable((context, model) => context.auth.id === model.ownerId)
```
#### Date Field
```typescript
date()
.nullable // Allow null values
.generate(() => new Date()) // Generate value on model creation
.validate([(value) => value > new Date('2000-01-01')]) // Validation rules
.readable((context, model) => context.auth.role === 'admin')
.writable((context, model) => context.auth.id === model.ownerId)
// Note: Date fields don't support .default() - use .generate() instead
```
#### Option Field
```typescript
option(['draft', 'published', 'archived'])
.nullable // Allow null values
.default('draft') // Static default value
.validate([(value) => value !== 'archived']) // Additional validation
.readable((context, model) => context.auth.role === 'admin')
.writable((context, model) => context.auth.role === 'editor')
```
#### List Field
```typescript
list<string>()
.nullable // Allow null values
.default([]) // Static default value
.default(() => ['default', 'items']) // Dynamic default value
.generate(() => [crypto.randomUUID()]) // Generate value on model creation
.validate([(items) => items.length <= 10]) // Validation rules
.readable((context, model) => context.auth.role === 'admin')
.writable((context, model) => context.auth.id === model.ownerId)
```
#### Record Field
```typescript
record<{ name: string; age: number }>()
.nullable // Allow null values
.default({ name: 'Anonymous', age: 0 }) // Static default value
.default(() => ({ name: 'User', age: new Date().getFullYear() - 2000 })) // Dynamic default
.generate(() => ({ id: crypto.randomUUID(), timestamp: Date.now() })) // Generate on creation
.validate([(obj) => obj.name && obj.name.length > 0]) // Validation rules
.readable((context, model) => context.auth.role === 'admin')
.writable((context, model) => context.auth.id === model.ownerId)
```
## Global Field Configuration
Set up global behaviors that apply to all fields of a specific type across your entire application:
### Global Generators
```typescript
import { IdField, TextField, DateField } from 'verve'
// Set global ID generation for all id() fields
IdField.setGlobalGenerator(() => crypto.randomUUID())
// Set global timestamp generation for all date() fields
DateField.setGlobalGenerator(() => new Date())
// Now all models automatically use these generators
({
id: id(), // Uses crypto.randomUUID() by default
createdAt: date() // Uses new Date() by default
})
class User extends Model.Typed<'User'>() {}
```
### Global Validators
```typescript
// Set global validation for all text() fields
TextField.setGlobalValidator((value) => {
if (typeof value !== 'string') return false
return value.trim().length > 0 // No empty strings allowed
})
// Set global validation for all date() fields
DateField.setGlobalValidator((value) => {
const minDate = new Date('1970-01-01')
const maxDate = new Date('2100-12-31')
return value >= minDate && value <= maxDate
});
// Global validators run on ALL fields of that type
({
name: text(), // Must pass TextField global validator
email: text(), // Must pass TextField global validator
birthDate: date() // Must pass DateField global validator
})
class User extends Model.Typed<'User'>() {}
```
### How Validators Combine
```typescript
// Global validator applies to all text fields
TextField.setGlobalValidator((value) => value.trim().length > 0);
({
// ✅ Adds to global validators - BOTH will run
name: text().validate([(value) => value.length <= 50]),
// ✅ Adds to global validators - ALL will run
email: text()
.validate([
(value) => value.includes('@'),
(value) => value.includes('.'),
]),
// ❌ Ignores global validators - ONLY custom validator runs
internalCode: text().validate.only([(value) => /^[A-Z0-9]+$/.test(value)])
})
class User extends Model.Typed<'User'>() {}
// Validation execution order:
// name: [global validator, length <= 50]
// email: [global validator, includes '@', includes '.']
// description: [global validator, then lazy length check]
// internalCode: [ONLY the regex validator - global ignored]
```
### Practical Use Cases
```typescript
// Security: Prevent XSS in all text fields
// Note: Use more comprehensive regex to secure your text fields
TextField.setGlobalValidator((value) => {
return !/<script|javascript:|on\w+=/i.test(value)
})
// Business rules: All dates must be reasonable (for our application use-case)
DateField.setGlobalValidator((value) => {
const now = new Date()
const minDate = new Date(0) // 1970-01-01
const maxDate = new Date('2050-01-01')
return value >= minDate && value <= maxDate
})
// Consistency: All IDs follow same format
IdField.setGlobalGenerator(() => crypto.randomUUID())
```
**Key Benefits:**
- **Consistency** - Same behavior across all models automatically
- **Security** - Global validation catches issues everywhere
- **DRY principle** - Define common rules once, apply everywhere
- **Flexibility** - Use `.validate.only()` when you need exceptions
## Field Access Methods
Every model has various helper methods that can help you retrieve or modify field values:
### Value Access
```typescript
const user = User.make({ name: 'John', age: 30 })
// Get field value (throws if not valid/readable/initialized)
user.name // 'John'
// Unsafe get (returns undefined if the field is not initialized and bypasses all validators and checks)
user.unsafeGet('name') // 'John' or undefined
// Set field value (throws if not writable)
user.set({ name: 'Jane' })
// Completely unset the value from the model's state (uninitialize)
user.unset('name')
```
### Value Checking
```typescript
// Check if field is empty/present
user.hasEmpty('name') // true if null/undefined or empty array/object
user.hasPresent('name') // opposite of the isEmpty method
// Check field validity
user.hasValid('name') // true if passes all validators
user.validate('name') // returns VerveErrorList related to 'name' field
user.validate() // returns VerveErrorList (merged errors from all fields)
```
### Generate Value
```typescript
// Generate field value (for fields with lazy .generate())
user.generate('id') // generates new ID
user.generate() // generates values for all fields that can be generated
```
**Prefer field methods to set values over direct assignment:**
```typescript
// ✅ Recommended
user.set({ email: 'new.com', name: 'Martin' })
// ⚠️ Also works but prefer above method
user.email = 'new.com'
user.name = 'Martin'
```
## Model Types & Instantiation
### Model Type Definitions
```typescript
// Untyped model - minimal type safety
class User extends Model {}
// Typed model before type generation
// Use this when you need to re-generate types or don't have any generated types yet
class User extends Model.Typed<any>() {}
// Typed model with generated types
class User extends Model.Typed<'User'>() {}
```
After running `npx verve`, your models get full type safety:
```typescript
// Before: Model.Typed<any>()
// After: Model.Typed<'User'>() - with complete type information
```
### Model Instantiation
**Critical difference between `make` and `from`:**
```typescript
// make() - Creating NEW models
// All fields passed to constructor are recorded as CHANGES
// This includes generated and default fields
// For example, lets assume the field types defines for the model:
// id: id(), <-- generated
// isActive: bool().default(false) <-- defualt
const newUser = User.make({
name: 'John',
email: 'john.com'
})
newUser.isNew() // true
newUser.getChanges() // { id: '1', name: 'John', email: 'john@example.com', isActive: false }
// from() - Hydrating EXISTING models (from DB, API, etc.)
// Only subsequent mutations are recorded as CHANGES
// This would NOT execute generate and default
const existingUser = User.from({
id: '123',
name: 'John',
email: 'john.com'
})
existingUser.isExisting() // true
existingUser.getChanges() // {} - no changes yet
existingUser.set({ name: 'Jane' })
existingUser.getChanges() // { name: 'Jane' } - only the mutation
// Manually generating fields on a model that was hydrated also fails
existingUser.generate('id') // ❌ Field cannot be generated on existing model
```
#### Model Properties
By default, all fields and their state is set on an instance of a model. Exception to this are non-readable properties.
```typescript
({
name: text(),
password: text().readable(false), // Sensitive information, always hidden
})
class User extends Model.Typed<'User'>() {}
// Let's hydrate the model and log it
const user = User.from({ name: 'Martin', password: '123456' })
// Since password not readable, it's filtered out from the object properties
console.log(user) // { name: 'Martin' }
// Same for when you try to JSON stringify the object
JSON.stringify(user) // '{"name":"Martin"}'
```
You may now think how to actually get the non-readable data for create/update operations.
```typescript
// Let's say we want to create new user and after validation, we want to store it in the DB
const user = User.make({ name: 'Martin', password: '123456' })
// How to get all the fields, including non-readable password?
user.getChanges() // { name: 'Martin', password: '123456' }
// Careful, 'getChanges' method won't give you any changes on models that were hydrated (existing) using 'from'
const user = User.from({ name: 'Martin', password: '123456' })
user.getChanges() // {}
// But works if I mutate the password now and try to get changes
user.set({ password: '12345678' })
user.getChanges() // { password: '12345678' }
```
## Model Instance Methods
Every model instance provides these methods:
### Change Tracking
```typescript
const user = User.from({ name: 'John', age: 30 })
user.set({ name: 'Jane', age: 31 })
user.set({ name: 'Joe' })
// Gets latest changes per field since hydration
user.getChanges() // { name: 'Joe', age: 31 }
// Gets detailed change log with all changes
user.getChangeLog() // Array of change objects with timestamps including all changes (not just the latest field change)
// Example after unsetting field
user.unset('name') // This erases change log for 'name' field
user.getChanges() // { age: 31 }
user.getChangeLog() // Would not contain any changes on 'name' field
// Check if model is new or existing
user.isNew() // false
user.isExisting() // true since model was created with 'from' method (hydrating existing model)
```
### Field Selection
```typescript
// Keep only specific fields (unset others)
user.only(['name']) // Only name remains (+ automatically all fields that used id() field type)
console.log(user) // User { id: '123', name: 'Martin' }
// Remove specific fields
user.except(['name'])
console.log(user) // User { id: '123', isActive: true }
// Note: ID fields are never removed by only() or except()
```
The `make` vs `from` distinction ensures you always know exactly what data has changed.
## Advanced Usage
### Dynamic Authorization Rules
```typescript
({
// Authorization based on field value
confidentialNotes: text().readable((context, doc, value) => {
// Only show if user has clearance level >= document level
return context.auth.clearanceLevel >= doc.securityLevel
}),
// Time-based access
temporaryData: text().readable((context) => {
const now = new Date();
const workHours = now.getHours() >= 9 && now.getHours() <= 17;
return context.auth.role === Role.ADMIN || workHours
})
})
class SecureDocument extends Model.Typed<'SecureDocument'>() {}
```
## Error Handling
Verve provides a comprehensive error handling system with structured error codes, customizable messages, and security-conscious error exposure.
### Error Types & Common Scenarios
Verve throws clear, actionable errors for security violations and validation failures:
```typescript
try {
// Validation error - business rule violation
const user = User.make({ age: 16 }) // ❌ Age must be >= 18
} catch (error) {
console.log(error.message) // "FIELD_VALIDATOR_FAILED: Field 'age' validator 'ageValidator' failed on model 'User'"
}
try {
// Authorization error - field not readable
console.log(user.ssn) // ❌ Field not readable by current context
} catch (error) {
console.log(error.message) // "FIELD_NOT_READABLE: Field 'ssn' is not readable on model 'User'"
}
try {
// Authorization error - method not authorized
user.promoteToAdmin() // ❌ Only admins can promote
} catch (error) {
console.log(error.message) // "UNAUTHORIZED_METHOD_CALL: Unauthorized to call method 'promoteToAdmin'"
}
try {
// Model instantiation error
new User() // ❌ Direct instantiation not allowed
} catch (error) {
console.log(error.message) // "DIRECT_INSTANTIATION_NOT_ALLOWED: Direct instantiation not allowed. Use .make() or .from() instead."
}
```
### Error Checking & Handling
Use the `VerveError` class to check for specific error types:
```typescript
import { VerveError, ErrorCode } from 'verve'
const errors = user.validate('ssn') // Returns VerveErrorList
// Check presence of the errors
if (errors.isPresent()) {
// Do something with these errors, throw or log, prevent further logic to be executed
// You can also check for specific error
if (errors.contains(ErrorCode.FIELD_VALIDATOR_FAILED)) {
// Invalid value provided
}
if (errors.contains(ErrorCode.FIELD_NOT_READABLE)) {
// Unauthorized access
}
}
// Check if errors list is empty
if (errors.isEmpty()) {
// No errors, good to go
}
```
**You can also use validate method on the model to validate all fields:**
```typescript
import { VerveError, ErrorCode } from 'verve'
const errors = user.validate() // Returns VerveErrorList
// This might contain validation errors for all fields that failed
if (errors.isEmpty()) {
// Execute your logic
}
```
### Custom Error Messages
Override default error messages for better user experience or localization:
```typescript
import { ErrorRegistry, ErrorCode } from 'verve'
// Register custom error messages
ErrorRegistry.register({
[ErrorCode.FIELD_NOT_READABLE]: 'You do not have permission to view this information.',
[ErrorCode.FIELD_NOT_WRITABLE]: 'This field cannot be modified.',
[ErrorCode.FIELD_VALIDATOR_FAILED]: 'The value you entered is not valid.',
[ErrorCode.UNAUTHORIZED_METHOD_CALL]: 'You are not authorized to perform this action.',
[ErrorCode.FIELD_NOT_NULLABLE]: 'This field is required and cannot be empty.'
})
// Now all errors use your custom messages
try {
user.ssn // ❌ Not readable
} catch (error) {
console.log(error.message)
// "FIELD_NOT_READABLE: You do not have permission to view this information."
}
```
### Message Templating
Error messages support dynamic templating with field and model information:
```typescript
// Some of the default messages use {{field}}, {{model}}, and other contextual data
ErrorRegistry.register({
[ErrorCode.FIELD_VALIDATOR_FAILED]: "Field '{{field}}' validator '{{validator}}' failed on model '{{model}}'",
[ErrorCode.FIELD_NOT_READABLE]: "Field '{{field}}' is not readable on model '{{model}}'",
[ErrorCode.ASSOCIATION_INCOMPLETE]: 'You must call .to(...) after .associate({{from}})',
})
// Templates are automatically populated with context
try {
user.set({ age: -5 }) // ❌ Validation fails
} catch (error) {
console.log(error.message)
// "FIELD_VALIDATOR_FAILED: The value for 'age' is invalid on model User"
}
```
### Production-Safe Error Hiding
Hide error codes in production to prevent information leakage:
```typescript
import { ErrorRegistry } from 'verve'
// In production, hide error codes from end users
if (process.env.NODE_ENV === 'production') {
ErrorRegistry.hideCodes()
}
// Now errors only show user-friendly messages without codes
try {
user.creditScore // ❌ Not readable
} catch (error) {
console.log(error.message)
// Development: "FIELD_NOT_READABLE: Field 'creditScore' is not readable on model 'User'"
// Production: "Field 'creditScore' is not readable on model 'User'"
}
```
Or differentiate between production and development errors completely:
```typescript
import { ErrorRegistry } from 'verve'
// In production, hide error codes from end users
if (process.env.NODE_ENV === 'production') {
ErrorRegistry.register({
[ErrorCode.FIELD_NOT_READABLE]: "You're not authorized to read '{{field}}'",
})
ErrorRegistry.hideCodes()
}
// Now errors only show user-friendly messages without codes or model information
try {
user.creditScore // ❌ Not readable
} catch (error) {
console.log(error.message)
// Development: "FIELD_NOT_READABLE: Field 'creditScore' is not readable on model 'User'"
// Production: "You're not authorized to read 'creditScore'"
}
```
### Error Logging & Monitoring
```typescript
import { VerveError, ErrorCode } from 'verve'
function logError(error: unknown) {
if (error instanceof VerveError) {
logVerveError(error)
return
}
// Rest of your error logging logic
}
function logVerveError(error: VerveError) {
// Log security violations for monitoring
if (error.is(ErrorCode.UNAUTHORIZED_METHOD_CALL) ||
error.is(ErrorCode.FIELD_NOT_READABLE)) {
logger.warn('Security violation:', {
error: error.message,
user: context.auth.id,
timestamp: new Date().toISOString()
})
}
// Log validation failures for data quality monitoring
if (error.is(ErrorCode.FIELD_VALIDATOR_FAILED)) {
logger.info('Validation failure:', {
error: error.message,
user: context.auth.id
})
}
}
```
The error system ensures your application fails securely with actionable feedback while maintaining security in production environments.
## TypeScript Integration
Full type safety with excellent IDE support:
```typescript
const user = User.make({ email: 'test.com' })
// ✅ TypeScript knows these field types
user.email // string
user.age // number | null
user.isActive // boolean
// ✅ Method parameters are typed
user.set({ email: 'new.com' }) // ✅ string
user.set({ age: 25 }) // ✅ number
user.set({ age: 'Hello' }) // ❌ TypeScript error
user.set({ random: 'Something' }) // ❌ TypeScript error
```
## Best Practices
### 1. **Defense in Depth**
```typescript
// Layer multiple security checks
({
creditScore: number()
.readable((context, user) => context.auth.id === user.id) // Only owner
.validate.only([(score) => score >= 300 && score <= 850]) // Valid range
})
class CreditReport extends Model.Typed<'CreditReport'>() {
((context, report) =>
context.auth.id === report.userId && // Must be owner
context.auth.verified === true // Must be verified
)
requestIncrease() { /* ... */ }
}
```
### 2. **Centralized Rules**
```typescript
// Define reusable authorization rules
const SecurityRules = {
isOwner: (context: any, model: any) => context.auth.id === model.ownerId,
isAdmin: (context: any) => context.auth.role === Role.ADMIN,
isOwnerOrAdmin: (context: any, model: any) =>
SecurityRules.isOwner(context, model) || SecurityRules.isAdmin(context)
}
({
privateData: text().readable(SecurityRules.isOwnerOrAdmin)
})
class SecureModel extends Model.Typed<'SecureModel'>() {}
```
### 3. **Fail Secure**
```typescript
// Always default to secure (no access)
({
sensitiveField: text().readable(false), // Default: no access
// Only grant specific permissions
publicField: text().readable(true)
})
class SecureByDefault extends Model.Typed<'SecureByDefault'>() {}
```
## Roadmap
We're continuously improving Verve to make domain modeling even more powerful and developer-friendly. Here's what's coming:
### 🎯 Custom Validator Errors
- **Field-level custom errors** - Define specific error messages for validation failures
- **Contextual error formatting** - Error messages that adapt based on user context and permissions
```typescript
// Not implemented yet, just an idea 🚨
text().validate([
(value) => value.length > 3,
{ message: 'Name must be at least 4 characters long' }
])
```
### 🔧 Custom Field Types
- **Type-specific utilities** - Field helpers tailored to each field type's unique needs
- **Advanced validation helpers** - Common validation patterns as reusable helpers
- **Field transformation utilities** - Built-in formatters and sanitizers
```typescript
// Not implemented yet, just an idea 🚨
email() // Text field with built-in email validation
currency() // Currency formatting and validation
```
Or by enhancing base fields types:
```typescript
// Not implemented yet, just an idea 🚨
date().businessDays() // Business day calculations
list().unique() // Ensure array items are unique
```
### 🔗 In-Memory Association Loading
- **Automatic association hydration** - Load related models from in-memory storage
- **Lazy loading strategies** - Load associations on-demand for performance
- **Circular reference handling** - Safe handling of complex model relationships
```typescript
// Not implemented yet, just an idea 🚨
({
posts: list('Post').loadFrom('memory'), // Auto-load from in-memory store
profile: record('Profile').lazy() // Load on first access
})
class User extends Model.Typed<'User'>() {}
```
### 🚀 Additional Features
- **Advanced caching strategies** - Built-in caching for computed fields and associations
- **Performance optimizations** - Zero-cost abstractions and faster field access
- **Developer tooling** - Better IDE support and debugging utilities
**Want to contribute or suggest features?** We'd love to hear from you! Please open an issue to discuss new ideas.
## License
MIT