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 fieldErrors. 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, fieldErrors; category is optional). The valid code values are the
lowercase snake_case members of StandardErrorCode (see
@objectstack/spec/api), e.g. validation_error, resource_conflict,
permission_denied.
import type { ErrorResponse, StandardErrorCode } from '@objectstack/spec/api';
type FieldError = NonNullable<ErrorResponse['error']['fieldErrors']>[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 fieldErrors?: 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,
[{ field: 'status', code: 'lock_conflict', message: 'Task is in terminal state' }],
);
}
}
class InvalidStatusTransitionError extends AppError {
constructor(from: string, to: string) {
super(
'invalid_field',
`Cannot transition from '${from}' to '${to}'`,
400,
[{ field: 'status', code: 'invalid_field', message: `Invalid transition: ${from} → ${to}` }],
);
}
}Error Codes: Per-field code values must be members of the StandardErrorCode enum (lowercase snake_case, e.g. invalid_field, value_out_of_range, duplicate_value). 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';
// Map a ZodError onto an AppError carrying field-level errors.
function createValidationError(zodError: z.ZodError): AppError {
const fieldErrors = zodError.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
code: 'invalid_field' as const,
}));
return new AppError(
'validation_error',
`Validation failed: ${fieldErrors.length} error(s)`,
400,
fieldErrors,
);
}
// 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 (and their
bulk 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.