Skip to content

Latest commit

 

History

History
359 lines (304 loc) · 10.3 KB

File metadata and controls

359 lines (304 loc) · 10.3 KB
id error-handling-user-friendly-messages
title User-Friendly Error Messages
category error-handling
skillLevel intermediate
tags
error-handling
user-experience
error-messages
localization
logging
lessonOrder 36
rule
description
User-Friendly Error Messages using Schema.
summary Internal error: "TypeError: Cannot read property 'value' of undefined at line 342". Users see this and think the app is broken. Developers see it in logs and can debug. But showing technical errors...

Problem

Internal error: "TypeError: Cannot read property 'value' of undefined at line 342". Users see this and think the app is broken. Developers see it in logs and can debug. But showing technical errors to users is wrong—it's confusing, scary, and doesn't help them fix the problem. You need two error representations: user-friendly messages for the UI and detailed technical info for logs and debugging.

Solution

import { Data, Effect } from "effect"

// ============================================
// 1. Define errors with multiple representations
// ============================================

class ValidationError extends Data.TaggedError("ValidationError") {
  constructor(
    readonly field: string,
    readonly value: any,
    readonly reason: string
  ) {
    super()
  }

  userMessage(): string {
    const fieldLabel = this.field
      .replace(/([A-Z])/g, " $1")
      .toLowerCase()
      .trim()

    return `Invalid ${fieldLabel}: ${this.reason}`
  }

  techMessage(): string {
    return `[ValidationError] Field '${this.field}' failed validation. Value: ${JSON.stringify(this.value)}. Reason: ${this.reason}`
  }

  loggingInfo(): Record<string, any> {
    return {
      type: "ValidationError",
      field: this.field,
      value: this.redactSensitive(this.value),
      reason: this.reason,
      timestamp: new Date().toISOString(),
    }
  }

  private redactSensitive(value: any): any {
    if (typeof value === "string") {
      if (
        this.field.toLowerCase().includes("password") ||
        this.field.toLowerCase().includes("token") ||
        this.field.toLowerCase().includes("secret")
      ) {
        return "[REDACTED]"
      }
      if (this.field.toLowerCase().includes("email")) {
        return value.replace(/(.{2})(.*)(@.*)/, "$1***$3")
      }
    }
    return value
  }
}

class NetworkError extends Data.TaggedError("NetworkError") {
  constructor(
    readonly statusCode: number,
    readonly endpoint: string,
    readonly originalError: Error
  ) {
    super()
  }

  userMessage(): string {
    switch (this.statusCode) {
      case 500:
      case 502:
      case 503:
        return "The service is temporarily unavailable. Please try again in a few moments."
      case 404:
        return "The resource you requested could not be found."
      case 401:
        return "Your session has expired. Please log in again."
      case 403:
        return "You don't have permission to access this resource."
      case 429:
        return "Too many requests. Please wait a moment before trying again."
      default:
        return "A network error occurred. Please check your connection and try again."
    }
  }

  techMessage(): string {
    return `[NetworkError] HTTP ${this.statusCode} from ${this.endpoint}. Cause: ${this.originalError.message}`
  }

  loggingInfo(): Record<string, any> {
    return {
      type: "NetworkError",
      statusCode: this.statusCode,
      endpoint: this.endpoint,
      errorMessage: this.originalError.message,
      stack: this.originalError.stack,
      timestamp: new Date().toISOString(),
    }
  }
}

class PaymentError extends Data.TaggedError("PaymentError") {
  constructor(
    readonly reason: string,
    readonly transactionId: string,
    readonly attemptCount: number
  ) {
    super()
  }

  userMessage(): string {
    if (this.reason.includes("insufficient")) {
      return "Your account has insufficient funds. Please add funds and try again."
    }
    if (this.reason.includes("expired")) {
      return "Your payment method has expired. Please update it."
    }
    if (this.reason.includes("declined")) {
      return "Your payment was declined. Please check your details and try again."
    }
    return "Payment processing failed. Please try again or contact support."
  }

  techMessage(): string {
    return `[PaymentError] Transaction ${this.transactionId} failed after ${this.attemptCount} attempts. Reason: ${this.reason}`
  }

  loggingInfo(): Record<string, any> {
    return {
      type: "PaymentError",
      transactionId: this.transactionId,
      reason: this.reason,
      attempts: this.attemptCount,
      timestamp: new Date().toISOString(),
    }
  }
}

// ============================================
// 2. Error context for HTTP responses
// ============================================

type ErrorResponse = {
  message: string
  code: string
  details?: Record<string, any>
}

const toErrorResponse = (
  error: ValidationError | NetworkError | PaymentError
): ErrorResponse => {
  return {
    message: error.userMessage(),
    code: error._tag,
    details: error._tag === "ValidationError" ? { field: error.field } : undefined,
  }
}

// ============================================
// 3. Logger service
// ============================================

class Logger {
  static error(
    error: ValidationError | NetworkError | PaymentError,
    context?: Record<string, any>
  ): void {
    const logData = {
      ...error.loggingInfo(),
      ...(context && { context }),
    }

    console.error("[ERROR LOG]", JSON.stringify(logData, null, 2))

    // In production, this would send to external logging service
    // e.g., Sentry, DataDog, CloudWatch
  }

  static techDebug(
    error: ValidationError | NetworkError | PaymentError
  ): void {
    console.debug("[TECH DEBUG]", error.techMessage())
  }
}

// ============================================
// 4. Simulate domain operations
// ============================================

const validateEmail = (email: string): Effect.Effect<string, ValidationError> =>
  Effect.gen(function* () {
    if (!email.includes("@")) {
      return yield* Effect.fail(
        new ValidationError("email", email, "Must contain @ symbol")
      )
    }
    return email
  })

const processPayment = (
  amount: number
): Effect.Effect<{ success: boolean }, PaymentError> =>
  Effect.gen(function* () {
    if (amount > 10000) {
      return yield* Effect.fail(
        new PaymentError(
          "insufficient_balance",
          "txn_12345",
          1
        )
      )
    }
    return { success: true }
  })

const fetchUserData = (): Effect.Effect<any, NetworkError> =>
  Effect.gen(function* () {
    return yield* Effect.fail(
      new NetworkError(503, "https://api.example.com/users", new Error("Service unavailable"))
    )
  })

// ============================================
// 5. Application logic
// ============================================

const appLogic = Effect.gen(function* () {
  console.log("=== Scenario 1: Validation Error ===\n")

  const validationResult = yield* validateEmail("invalid-email").pipe(
    Effect.either
  )

  if (validationResult._tag === "Left") {
    const error = validationResult.left
    console.log("📧 User sees:", error.userMessage())
    console.log("🔧 Dev sees:", error.techMessage())
    Logger.error(error, { context: "email_validation" })
    console.log()
  }

  console.log("=== Scenario 2: Network Error ===\n")

  const networkResult = yield* fetchUserData().pipe(
    Effect.either
  )

  if (networkResult._tag === "Left") {
    const error = networkResult.left
    console.log("👤 User sees:", error.userMessage())
    console.log("🔧 Dev sees:", error.techMessage())
    Logger.error(error, { context: "user_data_fetch" })
    console.log()
  }

  console.log("=== Scenario 3: Payment Error ===\n")

  const paymentResult = yield* processPayment(50000).pipe(
    Effect.either
  )

  if (paymentResult._tag === "Left") {
    const error = paymentResult.left
    console.log("💳 User sees:", error.userMessage())
    console.log("🔧 Dev sees:", error.techMessage())
    Logger.error(error, { context: "payment_processing", userId: "user_123" })
    console.log()
  }

  console.log("=== Scenario 4: API Response ===\n")

  const errors: (ValidationError | NetworkError | PaymentError)[] = []

  const emailErr = yield* validateEmail("bad").pipe(Effect.either)
  if (emailErr._tag === "Left") errors.push(emailErr.left)

  const paymentErr = yield* processPayment(15000).pipe(Effect.either)
  if (paymentErr._tag === "Left") errors.push(paymentErr.left)

  console.log("📤 API Response to client:")
  console.log(
    JSON.stringify(
      {
        success: false,
        errors: errors.map(toErrorResponse),
      },
      null,
      2
    )
  )

  console.log("\n📋 Log entries for monitoring:")
  for (const error of errors) {
    console.log(JSON.stringify(error.loggingInfo()))
  }

  return errors
})

// Run application
Effect.runPromise(appLogic)
  .then(() => console.log("\n✅ Error messaging complete"))
  .catch((error) => console.error(`Error: ${error.message}`))

Why This Works

Concept Explanation
Dual messages User message (friendly) vs tech message (detailed)
HTTP status codes Network error translates codes to meaningful user text
Structured logging Errors include context, timestamp, transaction ID
Sensitive redaction Passwords, tokens never logged in plaintext
Error codes API returns ValidationError, NetworkError for client handling
Context preservation Logs include field name, transaction ID for debugging
Localization ready User messages can be translated; tech messages stay English
Privacy aware User emails partially masked in logs; PII redacted

When to Use

  • API endpoints that must return errors to clients
  • Forms that show validation errors to users
  • Logging systems that need debugging info
  • Monitoring/alerting based on error patterns
  • Support tickets that need technical details
  • Compliance/audit logs that need to be searchable
  • Public APIs where error details might leak info
  • Multi-user systems with privacy concerns

Related Patterns