Error Handling
View SourceThis guide covers comprehensive error handling patterns when working with AshTypescript-generated RPC functions.
Overview
All generated RPC functions return a {success: true/false} structure instead of throwing exceptions. This approach provides:
- Explicit error handling: Forces you to handle both success and error cases
- Type safety: TypeScript knows the exact shape of success and error responses
- Predictable control flow: No unexpected thrown exceptions
- Rich error information: Detailed error messages with field paths and metadata
Basic Error Handling Pattern
The fundamental pattern for handling RPC responses:
import { createTodo } from './ash_rpc';
const result = await createTodo({
fields: ["id", "title"],
input: {
title: "New Todo",
userId: "user-id-123"
}
});
if (result.success) {
// Success case: access the data
console.log("Created todo:", result.data);
const todoId: string = result.data.id;
const todoTitle: string = result.data.title;
} else {
// Error case: handle the errors
result.errors.forEach(error => {
console.error(`Error: ${error.message}`);
if (error.fieldPath) {
console.error(`Field: ${error.fieldPath}`);
}
});
}Error Structure
Each error in the errors array contains:
interface AshError {
message: string; // Human-readable error message
fieldPath?: string; // Optional field path (e.g., "title", "user.email")
code?: string; // Optional error code for programmatic handling
vars?: Record<string, any>; // Additional error metadata
}Common Error Scenarios
Validation Errors
Validation errors occur when input data doesn't meet resource requirements:
import { createTodo } from './ash_rpc';
// Missing required field
const result = await createTodo({
fields: ["id", "title"],
input: {} // Missing required 'title' and 'userId' fields
});
if (!result.success) {
result.errors.forEach(error => {
console.error(`${error.fieldPath}: ${error.message}`);
// Output: "title: is required"
});
}
// Invalid field value
const result2 = await createTodo({
fields: ["id", "title"],
input: {
title: "", // Empty string when non-empty required
priority: "invalid-priority", // Invalid enum value
userId: "user-id-123"
}
});
if (!result2.success) {
result2.errors.forEach(error => {
if (error.fieldPath === "title") {
console.error("Title cannot be empty");
}
if (error.fieldPath === "priority") {
console.error("Invalid priority value");
}
});
}Not Found Errors
Handle cases where requested resources don't exist:
import { getTodo } from './ash_rpc';
const result = await getTodo({
fields: ["id", "title"],
input: { id: "nonexistent-id" }
});
if (!result.success) {
// Check if it's a not-found error
const notFoundError = result.errors.find(e =>
e.message.toLowerCase().includes("not found") ||
e.code === "not_found"
);
if (notFoundError) {
console.error("Todo not found");
// Show user-friendly message or redirect
} else {
console.error("Other error occurred:", result.errors);
}
}Authorization Errors
Handle permission and authentication errors:
import { updateTodo, buildCSRFHeaders } from './ash_rpc';
const result = await updateTodo({
fields: ["id", "title"],
primaryKey: "todo-123",
input: { title: "Updated Title" },
headers: buildCSRFHeaders()
});
if (!result.success) {
const authError = result.errors.find(e =>
e.code === "unauthorized" ||
e.code === "forbidden" ||
e.message.toLowerCase().includes("permission")
);
if (authError) {
console.error("You don't have permission to update this todo");
// Redirect to login or show permission error
}
}Network Errors
Handle network connectivity issues:
import { listTodos } from './ash_rpc';
try {
const result = await listTodos({
fields: ["id", "title"],
fetchOptions: {
signal: AbortSignal.timeout(5000) // 5 second timeout
}
});
if (!result.success) {
// Check for network-related errors
const networkError = result.errors.find(e =>
e.message.toLowerCase().includes("network") ||
e.message.toLowerCase().includes("timeout") ||
e.message.toLowerCase().includes("fetch")
);
if (networkError) {
console.error("Network error:", networkError.message);
// Show retry button or offline message
}
}
} catch (error) {
// Handle catastrophic failures (e.g., network completely down)
console.error("Request failed completely:", error);
// Show offline mode or error boundary
}Advanced Error Handling Patterns
Typed Error Handling
Create type-safe error handling utilities:
type ErrorCode =
| "validation_error"
| "not_found"
| "unauthorized"
| "forbidden"
| "network_error";
interface TypedError {
code: ErrorCode;
message: string;
fieldPath?: string;
}
function categorizeError(error: { message: string; code?: string; fieldPath?: string }): TypedError {
const msg = error.message.toLowerCase();
if (error.code === "unauthorized" || msg.includes("unauthorized")) {
return { code: "unauthorized", message: error.message, fieldPath: error.fieldPath };
}
if (error.code === "forbidden" || msg.includes("permission")) {
return { code: "forbidden", message: error.message, fieldPath: error.fieldPath };
}
if (error.code === "not_found" || msg.includes("not found")) {
return { code: "not_found", message: error.message, fieldPath: error.fieldPath };
}
if (msg.includes("network") || msg.includes("timeout")) {
return { code: "network_error", message: error.message, fieldPath: error.fieldPath };
}
return { code: "validation_error", message: error.message, fieldPath: error.fieldPath };
}
// Usage
const result = await createTodo({
fields: ["id", "title"],
input: {
title: "",
userId: "user-id-123"
}
});
if (!result.success) {
result.errors.forEach(error => {
const typed = categorizeError(error);
switch (typed.code) {
case "validation_error":
console.error(`Validation error on ${typed.fieldPath}: ${typed.message}`);
break;
case "unauthorized":
console.error("Please log in to continue");
break;
case "network_error":
console.error("Network error - please check your connection");
break;
// ... handle other cases
}
});
}Field-Specific Error Handling
Extract and handle errors for specific fields:
function getFieldError(errors: Array<{message: string; fieldPath?: string}>, fieldPath: string) {
return errors.find(e => e.fieldPath === fieldPath);
}
const result = await createTodo({
fields: ["id", "title"],
input: {
title: "",
dueDate: "invalid-date",
userId: "user-id-123"
}
});
if (!result.success) {
const titleError = getFieldError(result.errors, "title");
const dueDateError = getFieldError(result.errors, "dueDate");
if (titleError) {
// Show error next to title input field
console.error("Title error:", titleError.message);
}
if (dueDateError) {
// Show error next to due date input field
console.error("Due date error:", dueDateError.message);
}
}Error Recovery and Retry
Implement retry logic for transient failures:
async function withRetry<T>(
fn: () => Promise<{success: boolean; data?: T; errors?: any[]}>,
maxRetries = 3,
delayMs = 1000
): Promise<{success: boolean; data?: T; errors?: any[]}> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const result = await fn();
if (result.success) {
return result;
}
// Check if error is retryable
const isRetryable = result.errors?.some(e =>
e.message.toLowerCase().includes("network") ||
e.message.toLowerCase().includes("timeout") ||
e.code === "service_unavailable"
);
if (!isRetryable || attempt === maxRetries) {
return result;
}
// Exponential backoff
await new Promise(resolve => setTimeout(resolve, delayMs * Math.pow(2, attempt)));
}
return { success: false, errors: [{ message: "Max retries exceeded" }] };
}
// Usage
const result = await withRetry(() =>
listTodos({
fields: ["id", "title"],
headers: buildCSRFHeaders()
})
);
if (result.success) {
console.log("Todos:", result.data);
} else {
console.error("Failed after retries:", result.errors);
}Global Error Handler
Create a global error handler for consistent error management:
type ErrorHandler = (errors: Array<{message: string; code?: string; fieldPath?: string}>) => void;
let globalErrorHandler: ErrorHandler | null = null;
export function setGlobalErrorHandler(handler: ErrorHandler) {
globalErrorHandler = handler;
}
export async function rpcCall<T>(
fn: () => Promise<{success: boolean; data?: T; errors?: any[]}>
): Promise<{success: boolean; data?: T; errors?: any[]}> {
const result = await fn();
if (!result.success && globalErrorHandler) {
globalErrorHandler(result.errors || []);
}
return result;
}
// Set up global handler
setGlobalErrorHandler((errors) => {
errors.forEach(error => {
// Log to error tracking service
console.error("API Error:", error);
// Show toast notification for certain errors
if (error.code === "unauthorized") {
showToast("Please log in to continue");
} else if (error.code === "forbidden") {
showToast("You don't have permission for this action");
}
});
});
// Usage
const result = await rpcCall(() =>
createTodo({
fields: ["id", "title"],
input: { title: "New Todo" }
})
);Error Handling with Phoenix Channels
Channel-based RPC actions use callback handlers instead of return values:
import { Channel } from "phoenix";
import { createTodoChannel } from './ash_rpc';
createTodoChannel({
channel: myChannel,
fields: ["id", "title"],
input: {
title: "New Todo",
userId: "user-id-123"
},
resultHandler: (result) => {
if (result.success) {
console.log("Created:", result.data);
} else {
// Handle errors in result handler
result.errors.forEach(error => {
console.error(`Error: ${error.message}`);
if (error.fieldPath) {
console.error(`Field: ${error.fieldPath}`);
}
});
}
},
errorHandler: (error) => {
// Handle channel-level errors (connection issues, etc.)
console.error("Channel error:", error);
// Show reconnection UI or error message
},
timeoutHandler: () => {
// Handle request timeout
console.error("Request timed out");
// Show timeout message and retry option
}
});Best Practices
Always Handle Both Cases
Never assume success - always handle both success and error cases:
// Bad: Assumes success
const result = await createTodo({ fields: ["id"], input: { title: "Todo", userId: "user-id-123" } });
console.log(result.data.id); // Runtime error if not successful!
// Good: Explicit handling
const result = await createTodo({ fields: ["id"], input: { title: "Todo", userId: "user-id-123" } });
if (result.success) {
console.log(result.data.id);
} else {
console.error("Failed:", result.errors);
}Provide User-Friendly Error Messages
Transform technical errors into user-friendly messages:
function getUserFriendlyMessage(error: {message: string; code?: string}): string {
if (error.code === "validation_error" || error.message.includes("required")) {
return "Please check that all required fields are filled out correctly.";
}
if (error.code === "not_found") {
return "The requested item could not be found.";
}
if (error.code === "unauthorized") {
return "Please log in to continue.";
}
if (error.code === "forbidden") {
return "You don't have permission to perform this action.";
}
if (error.message.includes("network") || error.message.includes("timeout")) {
return "Network error. Please check your connection and try again.";
}
return "An unexpected error occurred. Please try again.";
}
const result = await createTodo({
fields: ["id", "title"],
input: {
title: "",
userId: "user-id-123"
}
});
if (!result.success) {
const userMessage = result.errors.map(getUserFriendlyMessage).join(" ");
showToast(userMessage);
}Log Errors for Debugging
Always log detailed errors for debugging while showing user-friendly messages:
const result = await createTodo({
fields: ["id", "title"],
input: {
title: "New Todo",
userId: "user-id-123"
}
});
if (!result.success) {
// Log detailed error for debugging
console.error("Create todo failed:", {
errors: result.errors,
timestamp: new Date().toISOString(),
userAction: "create_todo"
});
// Show user-friendly message
showToast("Failed to create todo. Please try again.");
}Use TypeScript Type Guards
Leverage TypeScript's type system for safer error handling:
function isSuccessResult<T>(
result: {success: true; data: T} | {success: false; errors: any[]}
): result is {success: true; data: T} {
return result.success === true;
}
const result = await createTodo({
fields: ["id", "title"],
input: {
title: "New Todo",
userId: "user-id-123"
}
});
if (isSuccessResult(result)) {
// TypeScript knows result.data exists
console.log(result.data.id);
console.log(result.data.title);
} else {
// TypeScript knows result.errors exists
console.error(result.errors);
}Related Documentation
- Basic CRUD Operations - Learn about basic RPC operations
- Phoenix Channels - Error handling with channel-based actions
- Lifecycle Hooks - Error handling in lifecycle hooks
- Troubleshooting - Common issues and solutions