Loading learning content...
Every time your software encounters an error that affects users, you have a communication challenge. The technical failure—a network timeout, a validation error, a resource conflict—must be translated into human language that informs without confusing, guides without overwhelming, and maintains trust without revealing sensitive details.
User-facing error messages are the voice of your application in moments of crisis. They can turn a frustrating experience into a manageable one, or they can deepen user confusion and erode trust. The difference between 'Error: NullPointerException at UserService.java:347' and 'We couldn't complete your request. Please try again in a few moments.' is the difference between abandonment and retention.
This page examines the craft of error message design from first principles—what makes messages effective, patterns for different error types, and the infrastructure needed to deliver the right message in the right context.
By completing this page, you will understand the psychology of error communication, master patterns for crafting clear and actionable messages, learn techniques for localizing errors across languages and cultures, and be equipped to build error message systems that enhance rather than damage user experience. These skills apply across web applications, mobile apps, APIs, and any software that communicates with humans.
Before examining technical patterns, we must understand how users perceive and react to errors. Error messages arrive at psychologically charged moments—users are trying to accomplish something, and your software has just told them they can't. Their emotional state influences how they process your message.
The Emotional Context of Errors
When users encounter errors, they typically experience a progression of emotions:
Your error message intercepts this emotional journey. A well-crafted message can short-circuit the negative progression by immediately providing clarity and a path forward. A poorly crafted message amplifies confusion and frustration.
| Anti-Pattern | Example | User Impact | Better Approach |
|---|---|---|---|
| Technical jargon | 'SQLException: ORA-01017' | Complete confusion; users don't understand | 'Unable to verify your account. Please try again.' |
| Blame the user | 'You entered invalid data' | Defensive reaction; damages trust | 'The email address format looks incorrect. Please check and try again.' |
| Vague messages | 'An error occurred' | No understanding; no action possible | 'We couldn't save your changes because the file is too large.' |
| No path forward | 'Request failed' | Users stuck with no options | 'Request failed. Please wait a moment and try again, or contact support.' |
| Excessive apology | 'We're so very sorry!!!' | Can seem insincere; doesn't solve problem | 'Something went wrong. Here's how to fix it:' |
| ALL CAPS or exclamation | 'ERROR!!! CRITICAL FAILURE!!!' | Alarm without information | 'We couldn't process your payment. No charges were made.' |
Read your error messages aloud as if speaking to a friend who's asking for help. If it sounds robotic, condescending, or unclear, rewrite it. The best error messages sound like a helpful colleague explaining what happened and what to do next.
Effective error messages share common structural elements, each serving a specific purpose. Understanding this anatomy helps you craft messages that consistently meet user needs.
The Four Components of Error Messages
A complete error message typically includes these elements, though not all are needed in every situation:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
/** * Error message builder that ensures consistent structure * while allowing customization for specific error types. */interface ErrorMessageComponents { /** What happened - required */ summary: string; /** Why it happened - optional context */ explanation?: string; /** What users can do - strongly recommended */ action?: string; /** Alternative actions if primary fails */ alternativeAction?: string; /** Support escalation path */ helpLink?: string; /** Technical reference for support */ referenceCode?: string;} class ErrorMessageBuilder { private components: ErrorMessageComponents; constructor(summary: string) { this.components = { summary }; } because(explanation: string): this { this.components.explanation = explanation; return this; } withAction(action: string): this { this.components.action = action; return this; } orAlternatively(alternativeAction: string): this { this.components.alternativeAction = alternativeAction; return this; } withHelp(helpLink: string): this { this.components.helpLink = helpLink; return this; } withReference(referenceCode: string): this { this.components.referenceCode = referenceCode; return this; } build(): UserFacingError { return new UserFacingError(this.components); }} class UserFacingError { constructor(private readonly components: ErrorMessageComponents) {} /** * Generate the user-facing message string. */ toUserMessage(): string { const parts: string[] = [this.components.summary]; if (this.components.explanation) { parts.push(this.components.explanation); } if (this.components.action) { parts.push(this.components.action); } if (this.components.alternativeAction) { parts.push(`If that doesn't work, ${this.components.alternativeAction}`); } if (this.components.referenceCode) { parts.push(`Reference: ${this.components.referenceCode}`); } return parts.join(' '); } /** * Generate structured data for rich UI rendering. */ toStructured(): { summary: string; explanation?: string; actions: Array<{ label: string; action: 'primary' | 'secondary' }>; helpLink?: string; referenceCode?: string; } { const actions: Array<{ label: string; action: 'primary' | 'secondary' }> = []; if (this.components.action) { actions.push({ label: this.components.action, action: 'primary' }); } if (this.components.alternativeAction) { actions.push({ label: this.components.alternativeAction, action: 'secondary' }); } return { summary: this.components.summary, explanation: this.components.explanation, actions, helpLink: this.components.helpLink, referenceCode: this.components.referenceCode }; }} // Usage examples demonstrating effective error messages const validationError = new ErrorMessageBuilder( "We couldn't create your account.") .because("The email address 'user@domain' is already registered.") .withAction("Try signing in instead, or use a different email address.") .withHelp("/help/account-recovery") .build(); const temporaryError = new ErrorMessageBuilder( "We're having trouble connecting right now.") .because("Our payment service is temporarily unavailable.") .withAction("Please wait a moment and try again.") .orAlternatively("try a different payment method") .withReference("REF-20240115-A7X2") .build(); const concurrencyError = new ErrorMessageBuilder( "Your changes couldn't be saved.") .because("Someone else updated this document while you were editing.") .withAction("Please refresh the page to see the latest version, then make your changes again.") .withHelp("/help/collaborative-editing") .build(); console.log(validationError.toUserMessage());// Output: "We couldn't create your account. The email address 'user@domain' // is already registered. Try signing in instead, or use a different // email address."Different categories of errors require different communication strategies. What works for a validation error doesn't work for a server outage. Understanding these categories enables you to apply appropriate patterns consistently.
Category 1: User Input Errors
These are errors where the user provided data that doesn't meet requirements. The key principle: be specific about what's wrong and how to fix it.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
/** * User input validation errors should pinpoint exactly what's wrong * and provide clear guidance on how to correct it. */const inputErrorPatterns = { // Format errors: show expected format invalidEmail: (value: string) => ({ summary: "Please enter a valid email address.", explanation: `'${value}' doesn't appear to be a valid email format.`, example: "Example: name@company.com" }), // Length errors: show current vs required passwordTooShort: (current: number, required: number) => ({ summary: "Your password is too short.", explanation: `Passwords must be at least ${required} characters. Yours has ${current}.`, action: "Add more characters to make it stronger." }), // Range errors: show valid range ageOutOfRange: (value: number, min: number, max: number) => ({ summary: "Please enter a valid age.", explanation: `Age must be between ${min} and ${max}. You entered ${value}.` }), // Required field: be gentle, not accusatory requiredField: (fieldName: string) => ({ summary: `${fieldName} is required.`, action: "Please fill in this field to continue." }), // Pattern mismatch: explain the pattern in human terms invalidPhoneFormat: (value: string) => ({ summary: "Please check your phone number.", explanation: "Phone numbers should include area code and number, like (555) 123-4567.", action: `You entered: ${value}` }), // File errors: be specific about the constraint fileTooLarge: (sizeInMB: number, maxSizeInMB: number) => ({ summary: "This file is too large to upload.", explanation: `Maximum file size is ${maxSizeInMB}MB. Your file is ${sizeInMB}MB.`, action: "Try compressing the file or using a smaller version." }), invalidFileType: (type: string, allowedTypes: string[]) => ({ summary: `'${type}' files aren't supported.`, explanation: `You can upload: ${allowedTypes.join(', ')}.`, action: "Please convert your file to a supported format." })}; /** * Multiple validation errors should be presented together * so users can fix everything in one pass. */interface ValidationResult { isValid: boolean; errors: Array<{ field: string; message: string; suggestion?: string; }>;} function formatMultipleValidationErrors(result: ValidationResult): string { if (result.isValid) return ''; const errorCount = result.errors.length; const header = errorCount === 1 ? "Please fix the following issue:" : `Please fix these ${errorCount} issues:`; const errorList = result.errors .map(e => `• ${e.field}: ${e.message}`) .join('\n'); return `${header}\n\n${errorList}`;}Category 2: Authentication and Authorization Errors
These require extra care—be clear enough to help legitimate users while avoiding information that helps attackers.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
/** * Authentication error messages balance helpfulness with security. * Never confirm whether an email/username exists in the system. */const authErrorPatterns = { // Login failure: don't reveal which field is wrong loginFailed: () => ({ summary: "We couldn't sign you in.", explanation: "Please check your email and password and try again.", // DO NOT say "incorrect password" or "email not found" action: "Forgot your password?", helpLink: "/reset-password" }), // Account locked: explain without revealing threshold accountLocked: () => ({ summary: "Your account has been temporarily locked.", explanation: "For security, we've limited access after several unsuccessful sign-in attempts.", action: "Please wait 30 minutes, then try again. Or reset your password now.", helpLink: "/reset-password" }), // Session expired: non-alarming, with clear action sessionExpired: () => ({ summary: "Your session has expired.", explanation: "For security, we sign you out after a period of inactivity.", action: "Please sign in again to continue." }), // Insufficient permissions: explain scope without revealing system structure accessDenied: (resourceType: string) => ({ summary: `You don't have access to this ${resourceType}.`, explanation: "Your account permissions don't include this action.", action: "Contact your administrator to request access.", // Don't reveal what permissions are needed or who has them }), // MFA required: guide through the process mfaRequired: (method: 'sms' | 'app' | 'email') => ({ summary: "Verification required.", explanation: methodDescriptions[method], action: "Please enter the code to continue." }), // Password reset: same message whether email exists or not passwordResetRequested: () => ({ summary: "Check your email.", explanation: "If an account exists with that email, we've sent password reset instructions.", action: "Didn't receive it? Check spam, or try a different email address." // Security: don't confirm whether email exists in system })}; const methodDescriptions = { sms: "We've sent a verification code to your phone.", app: "Enter the code from your authenticator app.", email: "We've sent a verification code to your email."};Category 3: Transient System Errors
These are temporary failures where retrying might succeed. The key: reassure users, encourage retry, don't alarm.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
/** * Transient error messages should encourage retry while managing expectations. */const transientErrorPatterns = { // Network issues: don't blame user's connection networkError: () => ({ summary: "Connection interrupted.", explanation: "We're having trouble reaching our servers.", action: "Please check your internet connection and try again.", retryable: true, retryAfterSeconds: 5 }), // Service temporarily unavailable serviceUnavailable: (expectedDuration?: string) => ({ summary: "We're temporarily unable to complete this request.", explanation: "Our service is experiencing high demand or maintenance.", action: expectedDuration ? `Please try again in ${expectedDuration}.` : "Please try again in a few minutes.", retryable: true }), // Timeout: emphasize no harm done requestTimeout: () => ({ summary: "This is taking longer than expected.", explanation: "The server is busy processing your request.", action: "Please wait a moment and try again. Your data hasn't been lost.", retryable: true, retryAfterSeconds: 30 }), // Rate limiting: explain without revealing exact limits rateLimited: (retryAfterSeconds: number) => ({ summary: "Please slow down a bit.", explanation: "You've made many requests in a short time.", action: `Wait ${formatDuration(retryAfterSeconds)} before trying again.`, retryable: true, retryAfterSeconds }), // Conflict: data changed, retry needed concurrentModification: (resourceType: string) => ({ summary: `This ${resourceType} was just updated.`, explanation: "Another user or session made changes while you were working.", action: "Please refresh to see the latest version, then try your changes again.", retryable: true })}; function formatDuration(seconds: number): string { if (seconds < 60) return `${seconds} seconds`; if (seconds < 3600) return `${Math.ceil(seconds / 60)} minutes`; return `${Math.ceil(seconds / 3600)} hours`;}Category 4: Permanent/Business Rule Errors
These won't be resolved by retrying. Be clear about why, and guide users to alternatives.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
/** * Business rule violations need clear explanation and alternative paths. */const businessRuleErrorPatterns = { // Insufficient funds: be specific about the gap insufficientFunds: (required: number, available: number, currency: string) => ({ summary: "Insufficient balance for this transaction.", explanation: `This requires ${formatCurrency(required, currency)}, but your available balance is ${formatCurrency(available, currency)}.`, action: "Add funds to your account, or choose a smaller amount.", helpLink: "/help/add-funds" }), // Item unavailable: offer alternatives outOfStock: (productName: string) => ({ summary: `"${productName}" is currently out of stock.`, explanation: "This item is temporarily unavailable.", action: "Would you like to be notified when it's back in stock?", alternativeAction: "Browse similar items" }), // Action not allowed in current state invalidStateTransition: (action: string, currentState: string) => ({ summary: `Can't ${action} right now.`, explanation: `This order is currently "${currentState}" and can't be modified.`, action: "Contact support if you need to make changes.", helpLink: "/help/order-status" }), // Quota exceeded: explain limits quotaExceeded: (resource: string, limit: number, period: string) => ({ summary: `You've reached your ${resource} limit.`, explanation: `Your plan allows ${limit} ${resource} per ${period}.`, action: "Upgrade your plan for higher limits, or wait until your quota resets.", helpLink: "/pricing" }), // Feature not available: guide to upgrade path featureNotAvailable: (featureName: string, requiredPlan: string) => ({ summary: `${featureName} isn't available on your current plan.`, explanation: `This feature requires ${requiredPlan} or higher.`, action: "Upgrade to unlock this feature.", helpLink: "/upgrade" }), // Geographic restriction regionNotSupported: (featureName: string) => ({ summary: `${featureName} isn't available in your region yet.`, explanation: "We're working to expand availability.", action: "Sign up to be notified when it becomes available." })}; function formatCurrency(amount: number, currency: string): string { return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);}Every error should clearly signal whether retrying might help. Include a retryable flag in your error response structure so UI code can appropriately show or hide retry buttons. Users shouldn't waste time retrying errors that will never succeed.
The same underlying error may require different messages depending on where it occurred and who is seeing it. A payment failure needs different wording in a mobile checkout than in an admin dashboard.
Factors That Influence Message Selection
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
/** * Context-aware error message resolution system. * Maps technical errors to user-appropriate messages based on context. */interface ErrorContext { userRole: 'customer' | 'admin' | 'developer' | 'support'; platform: 'web' | 'mobile' | 'api' | 'email' | 'voice'; locale: string; operationCategory: 'checkout' | 'account' | 'data' | 'admin' | 'general'; isFirstOccurrence: boolean;} interface ErrorMessageVariant { title: string; body: string; actions: Array<{ label: string; target: string }>; technicalDetails?: string; // Only shown to appropriate roles} class ContextualErrorResolver { private messageRegistry = new Map< string, Map<string, (errorData: any, context: ErrorContext) => ErrorMessageVariant> >(); /** * Register a message variant for a specific error code and context pattern. */ register( errorCode: string, contextPattern: string, generator: (errorData: any, context: ErrorContext) => ErrorMessageVariant ): this { if (!this.messageRegistry.has(errorCode)) { this.messageRegistry.set(errorCode, new Map()); } this.messageRegistry.get(errorCode)!.set(contextPattern, generator); return this; } /** * Resolve the appropriate message for an error in a given context. */ resolve( errorCode: string, errorData: any, context: ErrorContext ): ErrorMessageVariant { const codeMessages = this.messageRegistry.get(errorCode); if (!codeMessages) { return this.getDefaultMessage(errorCode, context); } // Try specific patterns first, then fall back to more general ones const patterns = this.generateContextPatterns(context); for (const pattern of patterns) { const generator = codeMessages.get(pattern); if (generator) { return generator(errorData, context); } } return this.getDefaultMessage(errorCode, context); } /** * Generate context patterns from most specific to most general. */ private generateContextPatterns(context: ErrorContext): string[] { const { userRole, platform, operationCategory } = context; return [ `${userRole}:${platform}:${operationCategory}`, // Most specific `${userRole}:${platform}:*`, `${userRole}:*:${operationCategory}`, `*:${platform}:${operationCategory}`, `${userRole}:*:*`, `*:${platform}:*`, `*:*:${operationCategory}`, '*:*:*' // Default fallback ]; } private getDefaultMessage(errorCode: string, context: ErrorContext): ErrorMessageVariant { const includeCode = context.userRole === 'developer' || context.userRole === 'support'; return { title: 'Something went wrong', body: 'We encountered an unexpected issue. Please try again.', actions: [{ label: 'Try again', target: 'retry' }], technicalDetails: includeCode ? `Error code: ${errorCode}` : undefined }; }} // Example registration of context-aware messagesconst resolver = new ContextualErrorResolver() // Payment failure for customer on mobile during checkout .register( 'PAYMENT_DECLINED', 'customer:mobile:checkout', (data, ctx) => ({ title: 'Payment unsuccessful', body: "Your card wasn't charged. Please try a different payment method.", actions: [ { label: 'Try another card', target: 'payment-method' }, { label: 'Contact support', target: 'support' } ] }) ) // Same error for admin in dashboard .register( 'PAYMENT_DECLINED', 'admin:web:*', (data, ctx) => ({ title: 'Payment Declined', body: `Transaction ${data.transactionId} was declined by the processor.`, actions: [ { label: 'View transaction details', target: `/admin/transactions/${data.transactionId}` }, { label: 'Retry manually', target: 'manual-retry' } ], technicalDetails: `Decline code: ${data.declineCode} | Processor: ${data.processorName}` }) ) // Same error for API consumers .register( 'PAYMENT_DECLINED', '*:api:*', (data, ctx) => ({ title: 'Payment Declined', body: 'The payment was declined by the card issuer.', actions: [], technicalDetails: JSON.stringify({ errorCode: 'PAYMENT_DECLINED', declineCode: data.declineCode, retryable: data.declineCode !== 'do_not_honor', documentation: 'https://api.example.com/docs/errors#payment-declined' }) }) );For applications serving international audiences, error messages must be translatable and culturally appropriate. This goes beyond simple string replacement—it requires designing for localization from the start.
Challenges in Error Message Localization
Dynamic values: Messages often include variable data (amounts, names, dates) that must be formatted according to locale conventions.
Pluralization: 'You have 1 item' vs 'You have 5 items' requires language-specific plural rules.
Sentence structure: Languages have different word orders; messages must be designed to allow reordering.
Tone and formality: Some cultures prefer formal address; others are more casual.
Text expansion: German text is often 30% longer than English; UI must accommodate.
Right-to-left languages: Layout and formatting must adapt.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
/** * Localized error message system using ICU MessageFormat. * Supports pluralization, gender, and locale-specific formatting. */import IntlMessageFormat from 'intl-messageformat'; interface LocalizedMessageBundle { [messageKey: string]: string; // ICU MessageFormat string} const errorMessages: Record<string, LocalizedMessageBundle> = { 'en-US': { 'error.validation.minLength': '{field} must be at least {minLength, number} {minLength, plural, one {character} other {characters}} long.', 'error.validation.maxItems': 'You can select at most {maxItems, number} {maxItems, plural, one {item} other {items}}. You selected {count, number}.', 'error.payment.declined': 'Your payment of {amount, number, currency} was declined. Please try a different payment method.', 'error.quota.exceeded': 'You have used {used, number} of {limit, number} {resource, select, storage {GB of storage} api_calls {API calls} users {user seats} other {{resource}}}.', 'error.date.past': '{field} must be after {minDate, date, long}.', 'error.general.tryAgain': 'Something went wrong. Please try again in {retryAfter, number} {retryAfter, plural, one {second} other {seconds}}.' }, 'es-ES': { 'error.validation.minLength': '{field} debe tener al menos {minLength, number} {minLength, plural, one {carácter} other {caracteres}}.', 'error.validation.maxItems': 'Puedes seleccionar como máximo {maxItems, number} {maxItems, plural, one {elemento} other {elementos}}. Seleccionaste {count, number}.', 'error.payment.declined': 'Tu pago de {amount, number, currency} fue rechazado. Por favor, prueba con otro método de pago.', // ... additional translations }, 'de-DE': { 'error.validation.minLength': '{field} muss mindestens {minLength, number} {minLength, plural, one {Zeichen} other {Zeichen}} lang sein.', // German uses same plural form for Zeichen, but other words differ // ... additional translations }, 'ja-JP': { 'error.validation.minLength': '{field}は{minLength, number}文字以上で入力してください。', // Japanese doesn't have plural forms in the same way 'error.payment.declined': '{amount, number, currency}のお支払いは拒否されました。別のお支払い方法をお試しください。', // ... additional translations }}; class LocalizedErrorFormatter { private cache = new Map<string, IntlMessageFormat>(); constructor(private defaultLocale: string = 'en-US') {} /** * Format an error message for the given locale with substituted values. */ format( messageKey: string, values: Record<string, string | number | Date>, locale?: string ): string { const effectiveLocale = locale || this.defaultLocale; const cacheKey = `${effectiveLocale}:${messageKey}`; // Get or create formatter let formatter = this.cache.get(cacheKey); if (!formatter) { const template = this.getTemplate(messageKey, effectiveLocale); formatter = new IntlMessageFormat(template, effectiveLocale); this.cache.set(cacheKey, formatter); } try { return formatter.format(values) as string; } catch (error) { console.error(`Error formatting message ${messageKey}:`, error); return this.getFallbackMessage(messageKey, values); } } private getTemplate(messageKey: string, locale: string): string { // Try exact locale match if (errorMessages[locale]?.[messageKey]) { return errorMessages[locale][messageKey]; } // Try language without region (e.g., 'es' if 'es-MX' not found) const language = locale.split('-')[0]; for (const bundleLocale of Object.keys(errorMessages)) { if (bundleLocale.startsWith(language) && errorMessages[bundleLocale][messageKey]) { return errorMessages[bundleLocale][messageKey]; } } // Fall back to default locale return errorMessages[this.defaultLocale]?.[messageKey] || messageKey; } private getFallbackMessage( messageKey: string, values: Record<string, string | number | Date> ): string { // Return a safe but informative fallback return `[${messageKey}] ${JSON.stringify(values)}`; }} // Usage exampleconst formatter = new LocalizedErrorFormatter(); // English output: "Password must be at least 8 characters long."console.log(formatter.format( 'error.validation.minLength', { field: 'Password', minLength: 8 }, 'en-US')); // Spanish output: "La contraseña debe tener al menos 8 caracteres."console.log(formatter.format( 'error.validation.minLength', { field: 'La contraseña', minLength: 8 }, 'es-ES')); // Currency formatting respects locale// Japanese: "¥1,234のお支払いは拒否されました。"console.log(formatter.format( 'error.payment.declined', { amount: 1234 }, 'ja-JP'));Never concatenate strings to build messages—use proper message formatting. Provide context comments for translators explaining where messages appear. Test with pseudolocalization (artificially lengthened strings) to catch layout issues. Include placeholders for values even if they seem obvious in English—other languages may need them in different positions.
Complex errors often have layers of detail that different audiences need. Progressive disclosure presents information in stages, starting with the essential and revealing technical details on demand.
The Disclosure Hierarchy
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
/** * Error display component with progressive disclosure. * Shows essential information first, with expandable details. */interface ErrorDisplayProps { error: { headline: string; guidance: string; context?: string; referenceId?: string; debugInfo?: Record<string, unknown>; actions: Array<{ label: string; onClick: () => void; primary?: boolean }>; retryable: boolean; retryAfterSeconds?: number; }; userRole: 'customer' | 'admin' | 'developer';} function ErrorDisplay({ error, userRole }: ErrorDisplayProps) { const [detailsExpanded, setDetailsExpanded] = useState(false); const showTechnicalDetails = userRole === 'admin' || userRole === 'developer'; const showDebugInfo = userRole === 'developer'; return ( <div className="error-container" role="alert" aria-live="assertive"> {/* Always visible: Headline */} <div className="error-headline"> <AlertIcon className="error-icon" /> <h2>{error.headline}</h2> </div> {/* Always visible: Guidance */} <p className="error-guidance">{error.guidance}</p> {/* Context with expand/collapse for longer explanations */} {error.context && ( <div className="error-context"> <button onClick={() => setDetailsExpanded(!detailsExpanded)} aria-expanded={detailsExpanded} > {detailsExpanded ? 'Hide details' : 'Show details'} </button> {detailsExpanded && ( <p className="error-context-text">{error.context}</p> )} </div> )} {/* Actions: Primary and secondary */} <div className="error-actions"> {error.actions.map((action, idx) => ( <button key={idx} onClick={action.onClick} className={action.primary ? 'btn-primary' : 'btn-secondary'} > {action.label} </button> ))} </div> {/* Retry countdown if applicable */} {error.retryable && error.retryAfterSeconds && ( <RetryCountdown seconds={error.retryAfterSeconds} onRetry={() => window.location.reload()} /> )} {/* Technical reference for support teams */} {showTechnicalDetails && error.referenceId && ( <div className="error-reference"> <span className="reference-label">Reference ID:</span> <code className="reference-code">{error.referenceId}</code> <CopyButton text={error.referenceId} /> </div> )} {/* Debug information for developers only */} {showDebugInfo && error.debugInfo && ( <details className="error-debug"> <summary>Developer Details</summary> <pre>{JSON.stringify(error.debugInfo, null, 2)}</pre> </details> )} </div> );} /** * Countdown component that shows time until retry is recommended. */function RetryCountdown({ seconds, onRetry }: { seconds: number; onRetry: () => void }) { const [remaining, setRemaining] = useState(seconds); useEffect(() => { if (remaining <= 0) return; const timer = setTimeout(() => setRemaining(r => r - 1), 1000); return () => clearTimeout(timer); }, [remaining]); if (remaining <= 0) { return ( <button onClick={onRetry} className="btn-primary"> Ready to retry - Click here </button> ); } return ( <p className="retry-countdown"> Retrying in {remaining} seconds... <button onClick={onRetry} className="btn-link">Try now</button> </p> );}Always provide a one-click copy mechanism for reference IDs and error codes. Users calling support shouldn't have to manually transcribe 'REF-20240115-A7X2K9M3'. A copy button turns a frustrating support call into a quick lookup.
Error messages require testing beyond typical functional testing. You need to verify that messages are clear, complete, accessible, and appear correctly across platforms and locales.
Testing Dimensions
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
/** * Automated tests for error message quality and completeness. */describe('Error Message Quality', () => { const allErrorCodes = getAllRegisteredErrorCodes(); const resolver = new ContextualErrorResolver(); const locales = ['en-US', 'es-ES', 'de-DE', 'ja-JP', 'zh-CN']; describe('completeness', () => { it('every error code should have at least a default message', () => { for (const code of allErrorCodes) { const message = resolver.resolve(code, {}, defaultContext); expect(message.title).not.toBe('Unknown error'); expect(message.body).toBeTruthy(); } }); it('every message should include at least one action', () => { for (const code of allErrorCodes) { const message = resolver.resolve(code, {}, defaultContext); expect(message.actions.length).toBeGreaterThanOrEqual(1); } }); }); describe('security', () => { it('no message should contain file system paths', () => { for (const code of allErrorCodes) { const message = resolver.resolve(code, sampleErrorData[code], defaultContext); const fullText = `${message.title} ${message.body}`; expect(fullText).not.toMatch(/[A-Z]:\\|\/usr\\/|\/var\\/|\/home\\//i); } }); it('no message should contain SQL or code fragments', () => { for (const code of allErrorCodes) { const message = resolver.resolve(code, sampleErrorData[code], defaultContext); const fullText = `${message.title} ${message.body}`; expect(fullText).not.toMatch(/SELECT |INSERT |UPDATE |DELETE |FROM |WHERE /i); expect(fullText).not.toMatch(/\.[a-z]+\([^)]*\)/i); // Method calls } }); it('no message should expose internal entity IDs not provided by user', () => { for (const code of allErrorCodes) { const message = resolver.resolve(code, sampleErrorData[code], defaultContext); const fullText = `${message.title} ${message.body}`; // Check for common internal ID patterns expect(fullText).not.toMatch(/[0-9a-f]{24}/i); // MongoDB ObjectIds expect(fullText).not.toMatch(/usr_[a-z0-9]+/i); // Internal user IDs } }); }); describe('localization', () => { it('every message should be available in all supported locales', () => { for (const code of allErrorCodes) { for (const locale of locales) { const context = { ...defaultContext, locale }; const message = resolver.resolve(code, {}, context); // Should not fall back to error code as message expect(message.title).not.toBe(code); } } }); it('messages with currency should format correctly for locale', () => { const currencyErrors = allErrorCodes.filter(c => c.includes('PAYMENT') || c.includes('FUNDS')); for (const code of currencyErrors) { for (const locale of locales) { const context = { ...defaultContext, locale }; const message = resolver.resolve(code, { amount: 1234.56 }, context); // Verify currency is formatted (not raw number) expect(message.body).not.toMatch(/1234\.56(?![0-9])/); } } }); }); describe('accessibility', () => { it('messages should not rely solely on color to convey meaning', () => { // This would be tested via UI component tests // Ensure error indicators include icons/text, not just color }); it('error messages should be screen reader friendly', () => { // Verify ARIA attributes in rendered components }); });}); /** * Usability testing helper: collect user comprehension data. */class ErrorMessageUsabilityTest { async runTestSession( errors: Array<{ code: string; data: any }>, testUser: TestUser ): Promise<UsabilityResults> { const results: UsabilityResults = { userId: testUser.id, timestamp: new Date(), comprehensionScores: [], feedbackNotes: [] }; for (const error of errors) { const message = resolver.resolve(error.code, error.data, { userRole: testUser.role, platform: 'web', locale: testUser.locale, operationCategory: 'general', isFirstOccurrence: true }); // Present message and collect feedback const response = await this.presentToUser(testUser, message); results.comprehensionScores.push({ errorCode: error.code, understoodProblem: response.rateUnderstanding(1, 5), understoodAction: response.rateActionClarity(1, 5), confidenceLevel: response.rateConfidence(1, 5), freeformFeedback: response.feedback }); } return results; }}User-facing error messages are where technical failures meet human experience. Getting them right requires understanding psychology, applying systematic patterns, and building infrastructure that supports context-aware, localized communication.
What's Next:
With user-facing messages mastered, the next page explores logging errors appropriately—how to capture the rich diagnostic information that developers and operators need to understand and resolve failures, without compromising security or performance.
You now understand the psychology of error communication, patterns for different error categories, context-aware message selection, localization approaches, and testing strategies. Apply these principles to transform error moments from frustrations into opportunities to demonstrate your application's quality and care for users.