Server-Side Error Handling
Best practices for handling and throwing errors in ObjectStack plugins — custom errors, hook propagation, validation, transactions, and safeParsePretty
Server-Side Error Handling
This guide covers best practices for handling and throwing errors within ObjectStack plugins and server-side code — from creating custom errors with proper codes to transaction rollback and integration with safeParsePretty().
Error Contract: ObjectStack defines a standardized error envelope, ErrorResponseSchema (exported from @objectstack/spec/api). It wraps an EnhancedApiError with a machine-readable code, httpStatus, optional category, and fields (one entry per offending value). This is the spec's target contract for plugin/hook authors to throw against — but the kernel REST server (@objectstack/rest) that serves /api/v1/data/* does not auto-translate an arbitrary thrown error into this envelope today. It emits a flatter { error, code } shape and only recognizes a small, fixed set of SCREAMING_SNAKE_CASE codes (VALIDATION_FAILED, PERMISSION_DENIED, RECORD_NOT_FOUND, CONCURRENT_UPDATE, DELETE_RESTRICTED, …). See the API Overview and Wire Format for both wire formats in use, and the Error Catalog for the full StandardErrorCode list.
1. Creating Custom Errors with Proper Error Codes
ObjectStack does not ship a base error class — there is no ObjectStackError
export. Instead, define a small server-side helper that extends the native
Error and carries the fields from the EnhancedApiError schema (code,
httpStatus, fields; category is optional). The valid code values are
SCREAMING_SNAKE (ADR-0112): a StandardErrorCode member (e.g. VALIDATION_ERROR,
RESOURCE_CONFLICT, PERMISSION_DENIED) or a code your package registered in
ERROR_CODE_LEDGER (see @objectstack/spec/api).
import type { ErrorResponse, StandardErrorCode } from '@objectstack/spec/api';
type FieldError = NonNullable<ErrorResponse['error']['fields']>[number];
// Domain helper: a throwable error that maps cleanly onto EnhancedApiError.
class AppError extends Error {
constructor(
public code: StandardErrorCode,
message: string,
public httpStatus: number,
public fields?: FieldError[],
) {
super(message);
this.name = 'AppError';
}
}
class TaskAlreadyCompletedError extends AppError {
constructor(taskId: string) {
super(
'RESOURCE_CONFLICT',
`Task ${taskId} is already completed and cannot be modified`,
409,
// No per-field entry: a terminal-state conflict is a condition of the
// RECORD, not a constraint some value violated, and the top-level
// RESOURCE_CONFLICT already says so. `fields[]` is for "this value is
// wrong", which is why its codes name constraints.
);
}
}
class InvalidStatusTransitionError extends AppError {
constructor(from: string, to: string) {
super(
'INVALID_FIELD',
`Cannot transition from '${from}' to '${to}'`,
400,
// `invalid_transition` — the catalog has a member for exactly this, and it
// is more useful to a form than a generic "this field is invalid".
[{ field: 'status', code: 'invalid_transition', message: `Invalid transition: ${from} → ${to}` }],
);
}
}Error Codes: The top-level code is SCREAMING_SNAKE (StandardErrorCode or a ledger-registered code). Per-field code values are a separate, lowercase catalog — FieldErrorCode, closed, naming the constraint the value violated (required, max_length, invalid_transition, …). See the Error Catalog; the two vocabularies and why they differ are ADR-0114. Use the category and httpStatus that match — ErrorHttpStatusMap in @objectstack/spec/api maps each category to its HTTP status.
2. Error Propagation in Hooks
Hooks are a primary extension point for business logic. A hook is a Hook
object (imported from @objectstack/spec/data) with object, events
(an array of lifecycle events such as beforeInsert / afterUpdate), and a
handler: async (ctx) => { ... }. Errors thrown from a hook handler abort the
operation and propagate to the API layer. The handler receives a HookContext:
the pending record's fields are exposed directly on ctx.input, the pre-change
snapshot on ctx.previous, and the persisted result (after hooks) on ctx.result.
Before Hooks (Validation / Guard)
import { Hook } from '@objectstack/spec/data';
export const ValidateStatusTransition: Hook = {
name: 'validate_status_transition',
object: 'task',
events: ['beforeUpdate'],
handler: async (ctx) => {
const changes = ctx.input as Record<string, any>;
const previous = ctx.previous ?? {};
// Guard: prevent modifying completed tasks
if (previous.status === 'done' && changes.status !== undefined) {
throw new TaskAlreadyCompletedError(previous.id);
}
// Validate status transitions
if (changes.status) {
const validTransitions: Record<string, string[]> = {
open: ['in_progress', 'cancelled'],
in_progress: ['open', 'done', 'blocked'],
blocked: ['in_progress', 'cancelled'],
done: [], // terminal state
cancelled: [], // terminal state
};
const allowed = validTransitions[previous.status as string] ?? [];
if (!allowed.includes(changes.status)) {
throw new InvalidStatusTransitionError(previous.status, changes.status);
}
}
},
};After Hooks (Side Effects)
Throwing from an after* hook surfaces an error, but the main write has already
been persisted. Swallow and log failures from non-critical side effects so the
response still succeeds:
export const NotifyOnCompletion: Hook = {
name: 'notify_on_completion',
object: 'task',
events: ['afterUpdate'],
handler: async (ctx) => {
const record = ctx.result as Record<string, any>;
if (record.status === 'done') {
try {
// notificationService is provided by your plugin
await notificationService.send({
to: record.assigned_to,
template: 'task_completed',
data: { taskTitle: record.title },
});
} catch (error) {
// Log but don't fail the response
console.error('Failed to send completion notification', {
taskId: record.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
},
};3. Validation Error Formatting
Format validation errors so the client can map them to specific form fields:
import { z } from 'zod';
import { Hook } from '@objectstack/spec/data';
import { zodIssuesToFields } from '@objectstack/rest';
// Map a ZodError onto an AppError carrying field-level errors.
//
// Do NOT hand-roll this mapping, and do not reach for a top-level code here: a
// per-field `code` is a `FieldErrorCode` naming the CONSTRAINT the value violated
// (ADR-0114), and Zod's own issue codes are Zod's API rather than the wire's.
// `zodIssuesToFields` does the translation the platform's own routes use —
// `too_small` becomes `min_length` / `min_value` / `min_items` depending on what
// was too small, and a MISSING property becomes `required` instead of the
// `invalid_type` Zod reports for it.
//
// One entry per issue is the common case, NOT a guarantee: Zod folds a failed
// `z.union` into a single issue whose message is the bare "Invalid input", so a
// union failure produces that entry PLUS the entries of the branch that explains
// it, addressed with the union's own path (#5014). Read `fields.length` as the
// number of field errors, never as the number of Zod issues.
function createValidationError(zodError: z.ZodError, input?: unknown): AppError {
const fields = zodIssuesToFields(zodError.issues, input);
return new AppError(
'VALIDATION_ERROR',
`Validation failed: ${fields.length} error(s)`,
400,
fields,
);
}
// Usage in a hook
export const ValidateTaskData: Hook = {
name: 'validate_task_data',
object: 'task',
events: ['beforeInsert'],
handler: async (ctx) => {
const data = ctx.input as Record<string, any>;
const schema = z.object({
title: z.string().min(1, 'Title is required').max(200),
priority: z.enum(['low', 'medium', 'high', 'critical']),
estimated_hours: z.number().min(0).max(1000).optional(),
});
const result = schema.safeParse(data);
if (!result.success) {
throw createValidationError(result.error);
}
},
};4. Error Logging and Monitoring
There is no global onError lifecycle event — the hook HookEvent enum only
covers before* / after* of find, insert, update, delete. Bulk
(multi: true) writes fire these same events, not separate *Many variants.
Each Hook does expose an onError strategy field
('abort' — the default — or 'log') that decides whether a handler failure
aborts the operation or is merely logged. For structured error logging, wrap the
risky work in a try/catch inside the handler and log there:
import { Hook } from '@objectstack/spec/data';
export const TaskWriteWithLogging: Hook = {
name: 'task_write_with_logging',
object: 'task',
events: ['beforeInsert', 'beforeUpdate'],
onError: 'abort', // failure aborts the write (default); use 'log' to continue
handler: async (ctx) => {
try {
await runBusinessRules(ctx.input);
} catch (error) {
const httpStatus = (error as AppError).httpStatus ?? 500;
const logEntry = {
code: (error as AppError).code,
message: error instanceof Error ? error.message : String(error),
httpStatus,
object: ctx.object,
event: ctx.event,
userId: ctx.session?.userId,
timestamp: new Date().toISOString(),
stack: httpStatus >= 500 && error instanceof Error ? error.stack : undefined,
};
if (httpStatus >= 500) {
console.error('Server error', logEntry);
await alertService.notify('server-error', logEntry);
} else if (httpStatus >= 400) {
console.warn('Client error', logEntry);
}
// Re-throw to propagate to the API layer
throw error;
}
},
};5. Error Recovery Patterns
Graceful Degradation
When a non-critical service fails, continue with a fallback:
import { Hook } from '@objectstack/spec/data';
export const EnrichTaskWithAI: Hook = {
name: 'enrich_task_with_ai',
object: 'task',
events: ['afterInsert'],
onError: 'log', // AI enrichment is non-critical — never abort the write
handler: async (ctx) => {
const record = ctx.result as Record<string, any>;
try {
const suggestions = await aiService.suggestTags(record.description);
if (suggestions.length > 0) {
// Scoped cross-object write — stays inside the current execution context
await ctx.api.object('task').updateById(record.id, {
ai_suggested_tags: suggestions,
});
}
} catch (error) {
// AI enrichment is non-critical — log and continue
console.warn('AI tag suggestion failed', {
taskId: record.id,
error: error instanceof Error ? error.message : String(error),
});
// Don't re-throw — the task was already created successfully
}
},
};Circuit Breaker
Prevent cascading failures when an external service is down:
class CircuitBreaker {
private failures = 0;
private lastFailure = 0;
private readonly threshold: number;
private readonly resetTimeout: number;
constructor(threshold = 5, resetTimeout = 60000) {
this.threshold = threshold;
this.resetTimeout = resetTimeout;
}
get isOpen(): boolean {
if (this.failures >= this.threshold) {
if (Date.now() - this.lastFailure > this.resetTimeout) {
this.failures = 0; // Reset after timeout
return false;
}
return true;
}
return false;
}
recordFailure() {
this.failures++;
this.lastFailure = Date.now();
}
recordSuccess() {
this.failures = 0;
}
async execute<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
if (this.isOpen) return fallback;
try {
const result = await fn();
this.recordSuccess();
return result;
} catch (error) {
this.recordFailure();
return fallback;
}
}
}
const emailCircuit = new CircuitBreaker(5, 60000);6. Transaction Rollback on Error
Ensure data consistency when multi-step operations fail:
The scoped context (ctx.api) exposes a transaction() method. Its callback
receives a transaction-bound ScopedContext. A ScopedContext has no top-level
insert/update methods — you reach an object's repository with
tx.object(name) and then call insert(data) / update(data) /
updateById(id, data) on it (the record id travels inside data for update).
If the callback throws, the driver rolls the transaction back.
import { Hook } from '@objectstack/spec/data';
export const CreateProjectWithTasks: Hook = {
name: 'create_project_with_tasks',
object: 'project',
events: ['afterInsert'],
handler: async (ctx) => {
const project = ctx.result as Record<string, any>;
// Wrap multi-step operations in a transaction
await ctx.api.transaction(async (tx) => {
// Create default tasks for the project
const defaultTasks = [
{ title: 'Project kickoff', status: 'open', project: project.id },
{ title: 'Requirements gathering', status: 'open', project: project.id },
{ title: 'Design review', status: 'open', project: project.id },
];
for (const task of defaultTasks) {
await tx.object('task').insert(task);
}
// Update project with task count (id inside the data payload)
await tx.object('project').update({
id: project.id,
task_count: defaultTasks.length,
});
// If any operation fails, all changes are rolled back
});
},
};7. Bulk Operation Error Handling
Handle partial failures in batch operations gracefully:
interface BulkOperationResult<T> {
succeeded: T[];
failed: Array<{ index: number; data: unknown; error: string }>;
}
// `engine` is a scoped context (e.g. ctx.api inside a hook).
async function bulkCreateTasks(
engine: { object(name: string): { insert(data: unknown): Promise<Record<string, unknown>> } },
tasks: Record<string, unknown>[]
): Promise<BulkOperationResult<Record<string, unknown>>> {
const result: BulkOperationResult<Record<string, unknown>> = {
succeeded: [],
failed: [],
};
for (let i = 0; i < tasks.length; i++) {
try {
const created = await engine.object('task').insert(tasks[i]);
result.succeeded.push(created);
} catch (error) {
result.failed.push({
index: i,
data: tasks[i],
error: error instanceof Error ? error.message : String(error),
});
}
}
// If everything failed, throw a combined error
if (result.succeeded.length === 0 && result.failed.length > 0) {
throw new AppError(
'batch_complete_failure',
`All ${result.failed.length} operations failed`,
400,
result.failed.map((f) => ({
field: `operations[${f.index}]`,
message: f.error,
code: 'invalid_field' as const,
})),
);
}
return result;
}8. Integration with safeParsePretty()
ObjectStack provides safeParsePretty() for Zod schemas that returns human-readable error messages:
import { safeParsePretty } from '@objectstack/spec';
import { ObjectSchema } from '@objectstack/spec/data';
function validateObjectDefinition(input: unknown) {
const result = safeParsePretty(ObjectSchema, input, 'object definition');
if (!result.success) {
// result.formatted is the multi-line, human-readable block;
// result.error is the underlying ZodError.
console.error(result.formatted);
// Output:
// ✗ fields.0.name: Invalid identifier 'firstName'. Must be lowercase snake_case...
// ✗ fields.1.type: Invalid field type 'dropdown'. Did you mean 'select'?
// ✗ name: Required property 'name' is missing.
// Convert to the standardized error format
throw new AppError(
'VALIDATION_ERROR',
'Invalid object definition',
400,
result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
code: 'invalid_field' as const,
})),
);
}
return result.data;
}Using safeParsePretty in Hooks
import { safeParsePretty } from '@objectstack/spec';
import { Hook } from '@objectstack/spec/data';
import { z } from 'zod';
const TaskBusinessRules = z.object({
estimated_hours: z.number().max(1000, 'Estimated hours cannot exceed 1000'),
due_date: z.string().refine(
(d) => new Date(d) > new Date(),
'Due date must be in the future'
),
});
export const ValidateBusinessRules: Hook = {
name: 'validate_business_rules',
object: 'task',
events: ['beforeInsert'],
handler: async (ctx) => {
const result = safeParsePretty(TaskBusinessRules, ctx.input);
if (!result.success) {
throw new AppError(
'VALIDATION_ERROR',
result.formatted,
400,
result.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
code: 'invalid_field' as const,
})),
);
}
},
};safeParsePretty vs safeParse: Always prefer safeParsePretty() in server-side code. It provides formatted error messages that are suitable for both logging and API responses, with "Did you mean?" suggestions for common typos.