Error Handling

Saferis uses a principled error type hierarchy defined in SaferisError.scala that enables proper pattern matching and eliminates unsafe casting. All database operations return ZIO effects with SaferisError as the error type.

Error Categories

Error TypeWhen It Occurs
ConstraintViolationPRIMARY KEY, FOREIGN KEY, UNIQUE, NOT NULL, or CHECK constraint violated
SyntaxErrorInvalid SQL syntax
DataErrorData type mismatch, division by zero, etc.
QueryErrorGeneral SQL execution errors
TimeoutStatement timeout fired (or query was canceled server-side); see Statement Timeouts
RetryableTransient failure the application can reasonably retry; see Retryable Errors
ConnectionErrorCannot acquire database connection
DecodingErrorCannot decode a column value to the expected Scala type
EncodingErrorCannot encode a parameter value for the prepared statement
ReturningOperationFailedINSERT/UPDATE/DELETE RETURNING returned no rows
SchemaValidationSchema verification found mismatches (see Schema Validation)
UnexpectedNon-SQL errors (wrapped in Unexpected)

SQL Error Classification

SQL errors are automatically categorized based on SQLState codes:

SQLState PrefixError TypeDescription
23ConstraintViolationIntegrity constraint violations
42SyntaxErrorSyntax errors or access violations
22DataErrorData exceptions
OtherQueryErrorGeneral query errors

Pattern Matching on Errors

Use pattern matching for type-safe error handling:

xa.run(for
  _      <- ddl.createTable[ErrorUser](ifNotExists = true)
  _      <- dml.insert(ErrorUser(-1, "alice@example.com", "Alice"))
  _      <- dml.insert(ErrorUser(-1, "bob@example.com", "Bob"))
  result <- sql"SELECT * FROM ${Table[ErrorUser]} WHERE ${Table[ErrorUser].email} = ${"alice@example.com"}"
    .queryOne[ErrorUser]
    .either
yield result match
  case Left(SaferisError.DecodingError(col, expected, _)) =>
    s"Failed to decode column '$col' as $expected"
  case Left(error) =>
    s"Other error: ${error.message}"
  case Right(user) =>
    s"Found user: ${user.map(_.name).getOrElse("none")}")
  .either
Right(Found user: Alice)

Handling Constraint Violations

{
  val schema = Schema[UniqueEmail].withUniqueConstraint(_.email).build
  xa.run(for
    _ <- ddl.createTable(schema)
    _ <- dml.insert(UniqueEmail(-1, "alice@example.com"))
    // Try to insert duplicate email
    result <- dml.insert(UniqueEmail(-1, "alice@example.com")).either
  yield result match
    case Left(SaferisError.ConstraintViolation(constraintType, _, _, _)) =>
      s"Constraint violation: $constraintType"
    case Left(error) =>
      s"Other error: ${error.message}"
    case Right(_) =>
      "Insert succeeded")
    .either
}
Right(Constraint violation: UNIQUE)

What the violation looks like unhandled

The previous example caught the error with .either. If you don't recover, the constraint violation propagates as a real failure; the actual SaferisError is shown below the snippet:

xa.run(for
  _ <- ddl.createTable[CrashKey](ifNotExists = true)
  _ <- dml.insert(CrashKey(1, "first"))
  _ <- dml.insert(CrashKey(1, "duplicate")) // duplicate primary key → constraint violation
yield ()): ZIO[Scope, SaferisError, Unit]
saferis.SaferisError$ConstraintViolation: ConstraintViolation(UNIQUE,None,org.postgresql.util.PSQLException: ERROR: duplicate key value violates unique constraint "error_handling_crash_keys_pkey"
  Detail: Key (id)=(1) already exists.,Some(insert into error_handling_crash_keys (id, label) values (?, ?)))

Converting Raw Exceptions

Use SaferisError.fromThrowable to wrap exceptions:

{
  // Convert a Throwable to SaferisError
  val sqlException = new java.sql.SQLException("duplicate key", "23505")
  val error        = SaferisError.fromThrowable(sqlException)
  // error: SaferisError.ConstraintViolation(...)

  // Non-SQL exceptions become Unexpected
  val runtimeException = new RuntimeException("something went wrong")
  val unexpectedError  = SaferisError.fromThrowable(runtimeException)
  // unexpectedError: SaferisError.Unexpected(...)
  (error, unexpectedError)
}
(ConstraintViolation(UNKNOWN,None,java.sql.SQLException: duplicate key,None),Unexpected(java.lang.RuntimeException: something went wrong))

Accessing Error Details

Each error type provides relevant details:

{
  def logError(error: SaferisError): String = error match
    case e: SaferisError.ConstraintViolation =>
      s"Constraint ${e.constraintType} violated. SQL: ${e.sql.getOrElse("N/A")}"
    case e: SaferisError.SyntaxError =>
      s"Syntax error: ${e.cause.getMessage}. SQL: ${e.sql.getOrElse("N/A")}"
    case e: SaferisError.DecodingError =>
      s"Failed to decode column '${e.columnName}' as ${e.expectedType}"
    case e: SaferisError.SchemaValidation =>
      s"Schema issues:\n${e.issues.map(_.description).mkString("\n")}"
    case e =>
      e.message

  val sample = SaferisError.fromThrowable(new java.sql.SQLException("duplicate key", "23505"))
  logError(sample)
}
Constraint UNKNOWN violated. SQL: N/A