Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .check.exs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
{:reuse, command: ["pipx", "run", "reuse", "lint", "-q"]},
{:credo, "mix credo --strict"},
{:sobelow, "mix sobelow --config"},
{:test_codegen, "mix test.codegen"},
{:compile_generated, "mix cmd --cd test/ts npm run compileGenerated"},
{:compile_should_pass, "mix cmd --cd test/ts npm run compileShouldPass"},
{:compile_should_fail, "mix cmd --cd test/ts npm run compileShouldFail"},
Expand Down
3 changes: 3 additions & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@
# Used by "mix format"
spark_locals_without_parens = [
argument_names: 1,
error_handler: 1,
field_names: 1,
fields: 1,
metadata_field_names: 1,
read_action: 1,
resource: 1,
resource: 2,
rpc_action: 2,
rpc_action: 3,
show_metadata: 1,
show_raised_errors?: 1,
ts_fields_const_name: 1,
ts_result_type_name: 1,
type_name: 1,
Expand Down
10 changes: 10 additions & 0 deletions documentation/dsls/DSL-AshTypescript.Rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ Define available RPC-actions for resources in this domain.



### Options

| Name | Type | Default | Docs |
|------|------|---------|------|
| [`error_handler`](#typescript_rpc-error_handler){: #typescript_rpc-error_handler } | `mfa \| module` | `{AshTypescript.Rpc.DefaultErrorHandler, :handle_error, []}` | An MFA or module that implements error handling for RPC operations. The error handler will be called with (error, context) and should return a modified error map. If a module is provided, it must export a handle_error/2 function. Example: ```elixir error_handler {MyApp.CustomErrorHandler, :handle_error, []} # or error_handler MyApp.CustomErrorHandler ``` |
| [`show_raised_errors?`](#typescript_rpc-show_raised_errors?){: #typescript_rpc-show_raised_errors? } | `boolean` | `false` | Whether to show detailed information for raised exceptions. Set to true in development to see full error details. Keep false in production for security. |



### typescript_rpc.resource
```elixir
resource resource
Expand Down Expand Up @@ -71,6 +80,7 @@ Example: `metadata_field_names [field_1: :field1, is_valid?: :isValid]`

| Name | Type | Default | Docs |
|------|------|---------|------|
| [`read_action`](#typescript_rpc-resource-rpc_action-read_action){: #typescript_rpc-resource-rpc_action-read_action } | `atom` | | The read action to use for update and destroy operations when finding records |
| [`show_metadata`](#typescript_rpc-resource-rpc_action-show_metadata){: #typescript_rpc-resource-rpc_action-show_metadata } | `nil \| boolean \| list(atom)` | | Which metadata fields to expose (nil=all, false/[]=none, list=specific fields) |
| [`metadata_field_names`](#typescript_rpc-resource-rpc_action-metadata_field_names){: #typescript_rpc-resource-rpc_action-metadata_field_names } | `list({atom, atom})` | `[]` | Map metadata field names to valid TypeScript identifiers |

Expand Down
305 changes: 305 additions & 0 deletions documentation/topics/error-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
<!--
SPDX-FileCopyrightText: 2025 ash_typescript contributors <https://github.com/ash-project/ash_typescript/graphs.contributors>

SPDX-License-Identifier: MIT
-->

# Error Handling

AshTypescript provides a comprehensive error handling system that transforms Ash framework errors into TypeScript-friendly JSON responses. Errors are returned with structured information that can be easily consumed by TypeScript clients.

## Error Response Format

All errors from RPC actions are returned in a standardized format:

```typescript
export type AshRpcError = {
type: string; // Error type (e.g., "not_found", "invalid_attribute")
message: string; // Error message template (may contain %{var} placeholders)
shortMessage?: string; // Brief error description
fields?: string[]; // Affected field names
path?: Array<string | number>; // Path to error location in data structure
vars?: Record<string, any>; // Variables for message interpolation
details?: Record<string, any>; // Additional error context
errorId?: string; // Unique error identifier for tracking
}
```

## Client-Side Variable Interpolation

Unlike server-side rendering, AshTypescript returns error messages as templates with separate variables. This allows clients to handle localization and formatting according to their needs:

```typescript
// Server returns:
{
type: "required",
message: "Field %{field} is required",
vars: { field: "email" },
fields: ["email"]
}

// Client can interpolate:
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;
}
```

## Error Types

AshTypescript implements protocol-based error handling for common Ash error types:

- `not_found` - Resource or record not found
- `required` - Required field missing
- `invalid_attribute` - Invalid attribute value
- `invalid_argument` - Invalid action argument
- `forbidden` - Authorization failure
- `forbidden_field` - Field-level authorization failure
- `invalid_changes` - Invalid changeset
- `invalid_query` - Invalid query parameters
- `invalid_page` - Invalid pagination parameters
- `invalid_keyset` - Invalid keyset for pagination
- `invalid_primary_key` - Invalid primary key value
- `unknown_field` - Unknown or inaccessible field
- `unknown_error` - Unexpected error

## Configuring Error Handlers

### Domain-Level Error Handler

Configure a custom error handler for all resources in a domain:

```elixir
defmodule MyApp.Domain do
use Ash.Domain,
extensions: [AshTypescript.Rpc]

rpc do
error_handler {MyApp.RpcErrorHandler, :handle_error, []}
end
end
```

### Resource-Level Error Handler

Configure error handling for specific resources:

```elixir
defmodule MyApp.Resource do
use Ash.Resource,
domain: MyApp.Domain,
extensions: [AshTypescript.Resource]

rpc do
error_handler {MyApp.ResourceErrorHandler, :handle_error, []}
end
end
```

When both domain and resource error handlers are defined, they are applied in sequence:
1. Resource error handler (if defined)
2. Domain error handler (if defined)
3. Default error handler

## Writing Custom Error Handlers

Error handlers receive the error and context, allowing for custom transformations:

```elixir
defmodule MyApp.RpcErrorHandler do
def handle_error(error, context) do
# Context includes:
# - domain: The domain module
# - resource: The resource module (if applicable)
# - action: The action being performed
# - actor: The current actor/user

case error.type do
"forbidden" ->
# Customize forbidden errors
%{error | message: "Access denied to this resource"}

"not_found" ->
# Add custom details for not found errors
%{error | details: Map.put(error.details || %{}, :support_url, "https://example.com/help")}

_ ->
# Pass through other errors unchanged
error
end
end
end
```

### Action-Specific Error Handling

You can customize errors based on the specific action that triggered them:

```elixir
defmodule MyApp.ResourceErrorHandler do
def handle_error(error, %{action: action} = context) do
case action.name do
:create ->
# Special handling for create actions
customize_create_error(error)

:update ->
# Special handling for update actions
customize_update_error(error)

_ ->
# Default handling
error
end
end

defp customize_create_error(%{type: "required"} = error) do
%{error | message: "This field is required when creating a new record"}
end

defp customize_create_error(error), do: error

defp customize_update_error(error), do: error
end
```

## Custom Error Types

To add support for custom Ash errors, implement the `AshTypescript.Rpc.Error` protocol:

```elixir
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
```

## Field Path Tracking

Errors include a `path` field (returned as camelCase `fieldPath` in JSON) that tracks the location of errors in nested data structures:

```javascript
// Error in nested relationship field
{
type: "unknown_field",
message: "Unknown field 'user.invalid_field'",
fieldPath: "user.invalid_field",
path: ["user"]
}

// Error in array element
{
type: "invalid_attribute",
message: "Invalid value at position %{index}",
vars: { index: 2 },
path: ["items", 2, "quantity"]
}
```

## Handling Multiple Errors

When multiple errors occur, they are returned as an array in the `errors` field:

```typescript
interface RpcErrorResponse {
success: false;
errors: AshRpcError[];
}

// Client handling
async function handleRpcCall(response: any) {
if (!response.success) {
response.errors.forEach((error: AshRpcError) => {
console.error(`${error.type}: ${interpolateMessage(error)}`);

// Handle specific error types
if (error.type === "forbidden") {
redirectToLogin();
} else if (error.type === "validation_error") {
highlightFields(error.fields);
}
});
}
}
```

## TypeScript Integration

The generated TypeScript client includes full type definitions for error handling:

```typescript
// Using generated RPC functions
import { createTodo } from './generated';

try {
const result = await createTodo({
title: "New Todo",
userId: "123"
});

if (result.success) {
console.log("Created:", result.data);
} else {
// TypeScript knows result.errors is AshRpcError[]
result.errors.forEach(error => {
if (error.type === "required") {
console.error(`Missing required field: ${error.fields?.[0]}`);
}
});
}
} catch (e) {
// Network or other errors
console.error("Request failed:", e);
}
```

## Best Practices

1. **Let the client handle interpolation**: Return message templates and variables separately for better localization support.

2. **Use specific error types**: Choose the most specific error type that matches the condition.

3. **Include field information**: Always populate the `fields` array for field-specific errors.

4. **Provide actionable messages**: Error messages should guide users on how to fix the issue.

5. **Track error paths**: Use the `path` field to indicate where in nested structures errors occurred.

6. **Add debugging context**: Use the `details` field to include additional debugging information (but be careful not to expose sensitive data).

7. **Handle errors gracefully in TypeScript**: Always check the `success` field before accessing `data` in responses.

## Differences from GraphQL Error Handling

Unlike AshGraphql which can interpolate variables server-side, AshTypescript intentionally returns templates and variables separately. This design choice provides:

- Better support for client-side localization
- Flexibility in message formatting
- Ability to use different messages for the same error type based on client context
- Reduced server-side processing

The error structure is also flattened compared to GraphQL's nested error format, making it easier to work with in TypeScript applications.
Loading
Loading