This skill should be used when adding error handling to catch blocks, standardizing error handling across a codebase, or ensuring proper UX with user messages vs technical logs. Covers NotificationOptions, Hooks.onError, and preventing console noise.
Scanned 2/12/2026
Install via CLI
openskills install majiayu000/claude-skill-registry---
name: fvtt-error-handling
description: This skill should be used when adding error handling to catch blocks, standardizing error handling across a codebase, or ensuring proper UX with user messages vs technical logs. Covers NotificationOptions, Hooks.onError, and preventing console noise.
---
# Foundry VTT Error Handling Patterns
**Domain:** Foundry VTT V12/V13 Error Handling
**Status:** Production-Ready (Verified against V13 API)
**Last Updated:** 2025-12-31
---
## Overview
This skill provides **production-ready error handling patterns** for Foundry VTT modules, using the documented V13 APIs: `NotificationOptions` and `Hooks.onError`.
### When to Use This Skill
- Adding error handling to catch blocks in Foundry modules
- Standardizing error handling across a codebase
- Ensuring proper UX (user messages vs technical logs)
- Preventing console noise and double-logging
- Enabling ecosystem compatibility (other modules can listen to your errors)
### Core Principle
**Always separate error funnel from user notification:**
- **Error funnel** (`Hooks.onError`): Logs stack traces, triggers ecosystem hooks, provides structured data
- **User notification** (`ui.notifications.*`): Clean, sanitized messages with full UX control
**Why separation matters:**
1. `Hooks.onError` mutates `error.message` when `msg` is provided (see Foundry GitHub #6669)
2. `clean: true` only works in NotificationOptions, not Hooks.onError
3. User sees only your controlled message, not technical details
4. Explicit control over console logging (`console: false` prevents double-logging)
---
## Quick Reference: Four Error Patterns
| Pattern | Error Funnel | User Notification | Use Case |
|---------|--------------|-------------------|----------|
| **User-facing** | Hooks.onError (`notify: null`) | ui.notifications.error | Unexpected failures |
| **Expected validation** | _(none)_ | ui.notifications.warn | User input errors |
| **Developer-only** | Hooks.onError (`notify: null`) | _(none)_ | Diagnostic logging |
| **High-frequency** | Throttled Hooks.onError | Throttled ui.notifications.warn | Render loops, hooks |
---
## Pattern 1: User-Facing Failures
**Use for:** Unexpected errors, operations that failed, critical failures
```javascript
} catch (err) {
// Preserve original error as cause when wrapping non-Errors
const error = err instanceof Error ? err : new Error(String(err), { cause: err });
// Error funnel: stack traces + ecosystem hooks (no UI)
Hooks.onError(`YourModule.${contextDescription}`, error, {
msg: "[YourModule]",
log: "error",
notify: null, // No UI from hook
data: { contextDescription, userFacingDescription } // Structured context
});
// Fully controlled user message (sanitized, no console - already logged)
ui.notifications.error(`[YourModule] ${userFacingDescription}`, {
clean: true,
console: false // Hooks.onError already logged
});
}
```
**Key points:**
- User sees only `userFacingDescription` (no technical details leaked)
- Stack trace logged via Hooks.onError (full Error object)
- Ecosystem visibility (other modules can listen to `Hooks.on("error", ...)`)
- Structured `data` for debugging and hook subscribers
- Explicit `console: false` prevents double-logging
---
## Pattern 2: Expected Validation / Recoverable Issues
**Use for:** User input errors, missing data, expected validation failures
```javascript
} catch (err) {
const message = `[YourModule] ${userFacingDescription}`;
ui.notifications.warn(message, {
clean: true,
console: false // Expected failures - no console noise
});
}
```
**Key points:**
- Uses `warn` severity (not `error`) for expected cases
- Simpler than Hooks.onError (no error funnel needed for routine validation)
- `console: false` avoids noise for common user-driven failures
- **Optional**: Gate console logging behind debug flag:
```javascript
console: game.settings.get("your-module", "enableProfiling")
```
---
## Pattern 3: Developer-Only Errors
**Use for:** Diagnostic logging, internal errors that don't need user notification
```javascript
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err), { cause: err });
Hooks.onError(`YourModule.${contextDescription}`, error, {
msg: "[YourModule]",
log: "error",
notify: null, // Log only, no UI spam
data: { contextDescription } // Structured context for debugging
});
}
```
**Key points:**
- Uses error funnel (consistency) but no notification
- Stack traces logged for developers
- No UI spam for internal diagnostics
- **Alternative**: Use `console.error(...)` for truly isolated cases (but lose ecosystem hooks)
---
## Pattern 4: High-Frequency Errors
**Use for:** Render loops, hooks, event handlers that might error repeatedly
```javascript
// Module-scoped throttle (outside class/function)
const errorThrottles = new Map();
// In catch block:
} catch (err) {
const throttleKey = contextDescription;
const lastError = errorThrottles.get(throttleKey) || 0;
// Throttle: 5 second window per context
if (Date.now() - lastError > 5000) {
const error = err instanceof Error ? err : new Error(String(err), { cause: err });
// Error funnel (no UI)
Hooks.onError(`YourModule.${contextDescription}`, error, {
msg: "[YourModule]",
log: "warn",
notify: null, // No UI from hook
data: { contextDescription, userFacingDescription }
});
// Separate controlled notification (console: false prevents double-logging)
ui.notifications.warn(`[YourModule] ${userFacingDescription}`, {
clean: true,
console: false // Already logged via Hooks.onError
});
errorThrottles.set(throttleKey, Date.now());
}
}
```
**Key points:**
- Throttles to prevent notification queue flood
- Module-scoped Map prevents spam across multiple instances
- Uses `warn` severity for non-critical repeated errors
- Separates error funnel (logs only) from user notification
- Explicit `console: false` prevents double-logging
---
## Decision Tree: Classifying Errors
**Ask these questions:**
1. **Is this unexpected?** (system failure, unhandled case, critical error)
→ **Pattern 1**: User-facing failures
2. **Is this expected validation?** (user input error, missing required data)
→ **Pattern 2**: Expected validation
3. **Is this diagnostic-only?** (internal state, no user impact)
→ **Pattern 3**: Developer-only
4. **Could this fire repeatedly?** (render loop, hook, event handler)
→ **Pattern 4**: High-frequency throttled
---
## Foundry-Specific Gotchas
### 1. `Hooks.onError` Mutates Error Objects
**Critical**: `Hooks.onError` modifies `error.message` when `msg` is provided:
```javascript
// Internal Foundry implementation (from GitHub #6669):
if (msg) err.message = `${msg}: ${err.message}`;
```
**This is why we separate funnel from notification:**
- Error funnel gets the Error object (stack traces preserved)
- User notification uses clean string (no mutation affects UX)
**Always normalize to Error objects:**
```javascript
const error = err instanceof Error ? err : new Error(String(err), { cause: err });
```
### 2. `console` Default Behavior Not Documented
**Issue**: Foundry V13 docs don't explicitly state the default value for `NotificationOptions.console`.
**Evidence**: API examples show `{ console: false }` to suppress logging, implying default is `true`.
**Best practice**: Be explicit to prevent double-logging and future-proof against default changes:
```javascript
ui.notifications.error(message, {
clean: true,
console: false // Explicit is better than implicit
});
```
### 3. `notify` String Values Are Undocumented
**Issue**: `Hooks.onError` accepts `notify: null | string`, but valid string values aren't enumerated in V13 API docs.
**Your assumption**: `notify` accepts `"error"`, `"warn"`, `"info"` (like `ui.notifications.*` methods)
**Reality**: Likely correct but NOT explicitly documented.
**Best practice**: Use `notify: null` + separate `ui.notifications.*` for full control:
```javascript
// Avoid relying on undocumented notify strings
Hooks.onError(..., { notify: null }); // Error funnel only
ui.notifications.error(...); // Separate notification
```
### 4. `clean: true` Only Works in NotificationOptions
**Issue**: `{ clean: true }` sanitizes untrusted input, but ONLY in `ui.notifications.*`.
**Does NOT work in `Hooks.onError`**: The `msg` parameter is NOT sanitized by Hooks.onError.
**Best practice**:
- Keep `msg` in Hooks.onError generic (module prefix only): `msg: "[YourModule]"`
- Put user/document data in separate `ui.notifications.*` call with `{ clean: true }`
```javascript
// ❌ Bad - untrusted data in Hooks.onError msg
Hooks.onError(..., { msg: `[Module] ${userInput}` }); // Not sanitized!
// ✅ Good - untrusted data in ui.notifications with clean: true
Hooks.onError(..., { msg: "[Module]", notify: null });
ui.notifications.error(`[Module] ${userInput}`, { clean: true });
```
### 5. `{ cause: err }` Has Excellent Support
**Good news**: Error `cause` parameter (ES2022) is widely supported:
- Available since September 2021 (4+ years)
- Foundry V13 minimum: Chromium 122, Firefox 127, Electron 33+
- **Safe to use** in all Foundry V13+ environments
**Usage**:
```javascript
const error = err instanceof Error ? err : new Error(String(err), { cause: err });
```
Preserves original error context for debugging in modern browsers.
---
## Implementation Checklist
When adding error handling to your module:
### 1. Audit All Catch Blocks
```bash
grep -r "} catch" scripts/
```
### 2. Classify Each Error
- [ ] Unexpected failure? → Pattern 1 (user-facing)
- [ ] Expected validation? → Pattern 2 (expected validation)
- [ ] Diagnostic only? → Pattern 3 (developer-only)
- [ ] High-frequency? → Pattern 4 (throttled)
### 3. Replace Patterns
- [ ] `console.log` → `console.error` or Hooks.onError (minimum fix)
- [ ] Manual notification + console → Hooks.onError (`notify: null`) + ui.notifications.*
- [ ] Expected failures → `ui.notifications.warn` with `console: false`
- [ ] Use `{ clean: true }` on ui.notifications.* for user/document data
- [ ] **Default to `console: false`** (prevents noise + double-logging)
- [ ] **Never** put untrusted strings in Hooks.onError `msg` (it's a prefix only)
- [ ] **Always** separate error funnel from user notification
- [ ] Preserve original error: `new Error(String(err), { cause: err })`
- [ ] Add structured `data` to Hooks.onError for debugging
- [ ] Throttle errors in render loops/hooks (prevent UI spam)
### 4. Test Error Paths
- [ ] Verify user-facing errors show clean message (no technical details leaked)
- [ ] Verify stack traces in console (Error objects via Hooks.onError)
- [ ] Verify `warn` severity used for expected validation errors
- [ ] Check that diagnostic errors don't spam UI (Hooks.onError with `notify: null`)
- [ ] Test throttling for high-frequency error paths (5 second window)
- [ ] Verify no double-logging (Hooks.onError + ui.notifications with `console: false`)
- [ ] Verify structured `data` appears in error hooks for debugging
---
## Verification Notes
**Verified against Foundry V13 API** (2025-12-31):
### ✅ Documented and Correct
- **`NotificationOptions.console`**: Optional boolean - "Whether to log the message to the console"
- **`NotificationOptions.clean`**: Optional boolean - "Whether to clean the provided message string as untrusted user input"
- **`Hooks.onError` signature**:
```typescript
static onError(
location: string,
error: Error,
options?: {
data?: object;
log?: null | string;
msg?: string;
notify?: null | string;
}
): void
```
- **All four parameters documented**:
- `msg`: String - "A message which should prefix the resulting error or notification"
- `log`: null | string - "The level at which to log the error to console (if at all)"
- `notify`: null | string - "The level at which to spawn a notification in the UI (if at all)"
- `data`: object - "Additional data to pass to the hook subscribers"
- **Notification types**: `"info"`, `"warn"`, `"error"` (all documented)
- **`notify: null`**: Explicitly documented as valid (suppresses UI notification)
- **`{ cause: err }`**: Excellent browser support (ES2022, 4+ years available)
### ⚠️ Undocumented Behaviors
- `console` default value not explicitly stated (defensive `console: false` recommended)
- `notify` string values not enumerated (use `notify: null` + separate notifications)
- `Hooks.onError` mutates `error.message` (normalize to Error objects, separate funnel from notification)
---
## Benefits of This Approach
- **Foundry-native**: Uses only documented NotificationOptions and Hooks.onError APIs
- **Full UX control**: Always separates error funnel from user message (no technical leaks)
- **Stack traces**: Error objects via Hooks.onError include full stack traces in console
- **Preserves context**: `{ cause: err }` retains original error when wrapping non-Errors
- **Structured debugging**: `data` parameter provides context to hook subscribers and debugging
- **Ecosystem-compatible**: Error funnel allows other modules to listen to your errors
- **Better UX**: Severity discipline (warn vs error) + throttling prevents notification spam
- **Sanitization**: `{ clean: true }` on ui.notifications.* prevents XSS from error messages
- **No double-logging**: Explicit `console: false` prevents duplicate entries
- **Reduced console noise**: Expected validation errors don't clutter console by default
- **Consistent pattern**: All errors use same "funnel + notification" approach
- **Robust throttling**: Module-scoped Map prevents spam across multiple instances
- **Future-proof**: Uses only documented Foundry V13 error handling mechanisms
---
## Optional Enhancement: Localization
For published modules, consider localizing error messages:
```javascript
const message = game.i18n.format("YOUR_MODULE.Error.FailedToAddItem", {
error: err.message
});
ui.notifications.error(message, { clean: true, console: false });
```
**Note**: If using `game.i18n.format`, the `format` function returns a sanitized string, so `clean: true` may be redundant (but harmless).
---
## References
### Foundry V13 API Documentation
- [NotificationOptions Interface](https://foundryvtt.com/api/interfaces/foundry.NotificationOptions.html)
- [Hooks.onError Method](https://foundryvtt.com/api/classes/foundry.helpers.Hooks.html)
- [Notifications Class](https://foundryvtt.com/api/classes/foundry.applications.ui.Notifications.html)
### GitHub Issues
- [Hooks.onError Implementation #6669](https://github.com/foundryvtt/foundryvtt/issues/6669) - Documents error.message mutation behavior
### Browser APIs
- [Error.cause - MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause)
- [Can I Use: Error.cause](https://caniuse.com/mdn-javascript_builtins_error_cause)
### Related Skills
- `foundry-vtt-performance-safe-updates` - Multi-client update safety patterns
- `foundry-vtt-dialog-compat` - DialogV2 Shadow DOM patterns
- `foundry-vtt-version-compat` - API compatibility layer patterns
---
## Example: Real-World Usage
```javascript
// In blades-alternate-actor-sheet.js
import { queueUpdate } from "./lib/update-queue.js";
async _onItemCreate(event) {
event.preventDefault();
const itemType = event.currentTarget.dataset.itemType;
try {
// Attempt to create item
const itemData = {
name: `New ${itemType}`,
type: itemType,
system: {}
};
await queueUpdate(async () => {
await this.actor.createEmbeddedDocuments("Item", [itemData]);
});
ui.notifications.info(`[BitD-Alt] Created new ${itemType}`);
} catch (err) {
// Pattern 1: User-facing failure
const error = err instanceof Error ? err : new Error(String(err), { cause: err });
Hooks.onError("BitD-Alt.ItemCreate", error, {
msg: "[BitD-Alt]",
log: "error",
notify: null,
data: { itemType, actorId: this.actor.id }
});
ui.notifications.error(`[BitD-Alt] Failed to create ${itemType}`, {
clean: true,
console: false
});
}
}
```
---
**Last Updated:** 2025-12-31
**Status:** Production-Ready (Verified against Foundry V13 API)
**Maintainer:** Claude Code (BitD Alternate Sheets)
No comments yet. Be the first to comment!