Error Handling

View Source

This guide covers error handling patterns for AshTypescript, including client-side handling and server-side customization.

Overview

All RPC functions return a discriminated union with success: true or success: false:

if (result.success) {
  // TypeScript knows result.data exists
  console.log(result.data);
} else {
  // TypeScript knows result.errors exists
  console.error(result.errors);
}

This approach provides:

  • Explicit error handling - Forces handling both success and error cases
  • Type safety - TypeScript knows the exact shape of each branch
  • Predictable control flow - No unexpected thrown exceptions
  • Rich error information - Structured errors with field paths and metadata

Error Structure

Each error in the errors array has this structure:

export type AshRpcError = {
  /** Machine-readable error type (e.g., "invalid_changes", "not_found") */
  type: string;
  /** Full error message (may contain template variables like %{key}) */
  message: string;
  /** Concise version of the message */
  shortMessage: string;
  /** Variables to interpolate into the message template */
  vars: Record<string, any>;
  /** List of affected field names (for field-level errors) */
  fields: string[];
  /** Path to the error location in the data structure */
  path: string[];
  /** Optional map with extra details */
  details?: Record<string, any>;
}

Common Error Types

TypeDescription
not_foundResource or record not found
requiredRequired field missing
invalid_attributeInvalid attribute value
invalid_argumentInvalid action argument
forbiddenAuthorization failure
forbidden_fieldField-level authorization failure
invalid_changesInvalid changeset
load_not_allowedRequested field not in allowed_loads
load_deniedRequested field in denied_loads
unknown_fieldUnknown or inaccessible field
unknown_errorUnexpected error

Basic Error Handling

import { createTodo } from './ash_rpc';

const result = await createTodo({
  fields: ["id", "title"],
  input: { title: "New Todo" }
});

if (result.success) {
  console.log("Created:", result.data);
} else {
  result.errors.forEach(error => {
    console.error(`${error.type}: ${error.message}`);
    if (error.fields.length > 0) {
      console.error(`Fields: ${error.fields.join(', ')}`);
    }
  });
}

Field-Specific Errors

Extract errors for specific fields:

function getFieldError(
  errors: AshRpcError[],
  fieldName: string
): AshRpcError | undefined {
  return errors.find(e => e.fields.includes(fieldName));
}

const result = await createTodo({
  fields: ["id", "title"],
  input: { title: "", dueDate: "invalid-date" }
});

if (!result.success) {
  const titleError = getFieldError(result.errors, "title");
  const dueDateError = getFieldError(result.errors, "dueDate");

  if (titleError) {
    setFieldError("title", titleError.message);
  }
  if (dueDateError) {
    setFieldError("dueDate", dueDateError.message);
  }
}

Error Categories

Categorize errors for different handling strategies:

type ErrorCategory = "validation" | "auth" | "not_found" | "network" | "other";

function categorizeError(error: AshRpcError): ErrorCategory {
  switch (error.type) {
    case "required":
    case "invalid_attribute":
    case "invalid_argument":
    case "invalid_changes":
      return "validation";

    case "unauthorized":
    case "forbidden":
    case "forbidden_field":
      return "auth";

    case "not_found":
      return "not_found";

    default:
      if (error.message.toLowerCase().includes("network")) {
        return "network";
      }
      return "other";
  }
}

// Usage
if (!result.success) {
  const categories = result.errors.map(categorizeError);

  if (categories.includes("auth")) {
    redirectToLogin();
  } else if (categories.includes("validation")) {
    showValidationErrors(result.errors);
  } else if (categories.includes("not_found")) {
    show404Page();
  }
}

Message Interpolation

Error messages may contain template variables. Interpolate them for display:

function interpolateMessage(error: AshRpcError): string {
  let message = error.message;
  if (error.vars) {
    Object.entries(error.vars).forEach(([key, value]) => {
      message = message.replace(`%{${key}}`, String(value));
    });
  }
  return message;
}

// Example: "Field %{field} is required" with vars: {field: "email"}
// Result: "Field email is required"

User-Friendly Messages

Transform technical errors into user-friendly messages:

function getUserMessage(error: AshRpcError): string {
  switch (error.type) {
    case "required":
      return `Please fill in the ${error.fields[0] || 'required'} field.`;
    case "not_found":
      return "The requested item could not be found.";
    case "forbidden":
      return "You don't have permission to perform this action.";
    case "load_not_allowed":
    case "load_denied":
      return "Some requested data is not available.";
    default:
      return "An error occurred. Please try again.";
  }
}

Retry Logic

Implement retry for transient failures:

async function withRetry<T>(
  fn: () => Promise<{ success: boolean; data?: T; errors?: AshRpcError[] }>,
  maxRetries = 3
): Promise<{ success: boolean; data?: T; errors?: AshRpcError[] }> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const result = await fn();

    if (result.success) return result;

    // Only retry network errors
    const isRetryable = result.errors?.some(e =>
      e.message.toLowerCase().includes("network") ||
      e.message.toLowerCase().includes("timeout")
    );

    if (!isRetryable || attempt === maxRetries) return result;

    // Exponential backoff
    await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
  }

  return { success: false, errors: [{ type: "max_retries", message: "Max retries exceeded" }] };
}

// Usage
const result = await withRetry(() =>
  listTodos({ fields: ["id", "title"] })
);

Global Error Handling

For global error handling across all RPC calls, use Lifecycle Hooks instead of wrapper functions. Configure an afterRequest hook once, and it automatically runs after every RPC call:

# config/config.exs
config :ash_typescript,
  rpc_action_after_request_hook: "RpcHooks.afterRequest"

See Global Error Handling with Lifecycle Hooks below for the complete implementation.

Global Error Handling with Lifecycle Hooks

For global error handling (logging, monitoring, auth redirects), use Lifecycle Hooks. The afterRequest hook receives the result and can perform side effects:

# config/config.exs
config :ash_typescript,
  rpc_action_after_request_hook: "RpcHooks.afterRequest",
  import_into_generated: [
    %{import_name: "RpcHooks", file: "./rpcHooks"}
  ]
// rpcHooks.ts
import type { ActionConfig } from './generated';

export async function afterRequest(
  actionName: string,
  response: Response,
  result: any | null,
  config: ActionConfig
): Promise<void> {
  // Handle failed responses
  if (result === null) {
    console.error(`[RPC] ${actionName} failed:`, response.status);
    return;
  }

  // Handle application errors
  if (!result.success) {
    result.errors?.forEach((error: any) => {
      // Log to monitoring service
      console.error(`[RPC] ${actionName} error:`, error);

      // Global auth error handling
      if (error.type === "forbidden" || error.type === "unauthorized") {
        // Redirect to login, show session expired message, etc.
        window.location.href = "/login";
      }
    });
  }
}

This approach centralizes error handling without needing wrapper functions around every RPC call.

Custom Error Protocol

For custom Ash errors, implement the AshTypescript.Rpc.Error protocol:

defmodule MyApp.CustomError do
  use Splode.Error, fields: [:field, :reason], class: :invalid

  def message(error) do
    "Custom validation failed for #{error.field}: #{error.reason}"
  end
end

defimpl AshTypescript.Rpc.Error, for: MyApp.CustomError do
  def to_error(error) do
    %{
      message: "Field %{field} failed validation: %{reason}",
      short_message: "Validation failed",
      type: "custom_validation_error",
      vars: %{field: error.field, reason: error.reason},
      fields: [error.field],
      path: []
    }
  end
end

Phoenix Channel Errors

Channel-based RPC uses callbacks for error handling:

import { createTodoChannel } from './ash_rpc';

createTodoChannel({
  channel: myChannel,
  fields: ["id", "title"],
  input: { title: "New Todo" },

  resultHandler: (result) => {
    if (result.success) {
      console.log("Created:", result.data);
    } else {
      // Handle application errors
      result.errors.forEach(error => {
        console.error(`${error.type}: ${error.message}`);
      });
    }
  },

  errorHandler: (error) => {
    // Handle channel-level errors (connection issues)
    console.error("Channel error:", error);
  },

  timeoutHandler: () => {
    // Handle request timeout
    console.error("Request timed out");
  }
});

Best Practices

  1. Always handle both cases - Never assume success
  2. Log detailed errors - Log full error objects for debugging
  3. Show user-friendly messages - Transform technical errors for users
  4. Use field paths - Highlight specific fields with errors
  5. Implement retry logic - Retry transient network failures
  6. Handle auth errors globally - Redirect to login when needed

Next Steps