| title | Server-Side Error Handling |
|---|---|
| description | Best practices for handling and throwing errors in ObjectStack plugins — custom errors, hook propagation, validation, transactions, and safeParsePretty |
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().
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.
{/* os:check */}
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}` }],
);
}
}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.
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);
}
}
},
};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),
});
}
}
},
};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);
}
},
};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;
}
},
};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
}
},
};Prevent cascading failures when an external service is down:
{/* os:check */}
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);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
});
},
};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;
}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;
}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,
})),
);
}
},
};