🎉 Native Google & Apple sign-in is here → read the guide
Results & Errors

Results & Errors

This is the one idea to learn before anything else — once it clicks, every other page in these docs reads the same way.

The big idea

Anything that talks to the network can fail: the server might be down, the user might be offline, a row might not exist. Most libraries handle this by throwing an exception — an invisible trapdoor that crashes your app unless you remember to wrap the call in try/catch.

Supabase KMP does it differently. Every call that can fail hands you back a little box called a SupabaseResult. The box contains either your value or an error — and the compiler makes you open it before you can use what’s inside. You can’t forget to handle the failure, because the box won’t let you skip it.

public sealed interface SupabaseResult<out T> {
    public data class Success<out T>(val value: T) : SupabaseResult<T>   // got the value
    public data class Failure(val error: SupabaseError) : SupabaseResult<Nothing> // got an error
}

So a call never throws on the happy path — it returns one of these two cases, and you decide what to do with each.

Consuming a result

val result = database.selectTyped<Todo>(table = "todos")
 
result
    .onSuccess { todos -> render(todos) }
    .onFailure { error -> log(error.message) }

Extracting a value

FunctionReturnsNotes
getOrNull()T?null on failure
getOrThrow()Tthrows SupabaseException on failure
getOrElse { error -> … }Tcompute a fallback from the error
errorOrNull()SupabaseError?null on success
fold(onSuccess, onFailure)Rcollapse both branches to one value
toKotlinResult()kotlin.Result<T>interop with stdlib

Transforming & chaining

FunctionPurpose
map { value -> … }transform the success value
flatMap { value -> SupabaseResult<R> }chain another fallible call
mapError { error -> … }rewrite the error
flatMapError { error -> SupabaseResult<T> }recover via another fallible call
recover { error -> value }turn a failure into a success
validate(predicate, lazyError)fail a success that breaks an invariant (predicate first)
zip(other) { a, b -> … }combine two results, short-circuiting on the first failure
// Guard a success value, then combine two independent reads:
val profile = database.selectSingleTyped<Profile>(table = "profiles") { eq("id", uid) }
    .validate(
        predicate = { it.isComplete },
        lazyError = { SupabaseError("Profile is incomplete") },
    )
 
val dashboard = profile.zip(loadSettings()) { p, settings -> Dashboard(p, settings) }

Suspending variants exist for async transforms: mapSuspend, foldSuspend, recoverSuspend, flatMapErrorSuspend, onSuccessSuspend, onFailureSuspend, onFailureCategorySuspend.

Side-effects

auth.signInWithEmail(email, password)
    .onSuccess { sessionManager.saveSession(it) }
    .onUnauthorized { showInvalidCredentials() }
    .onRateLimited { showTryLater() }
    .onNetworkError { showOfflineBanner() }
    .onFailure { logUnexpected(it) }

onSuccess, onFailure, onConflict, onNotFound, onUnauthorized, onRateLimited, onNetworkError, and onFailureCategory(category) { … } all return the result so you can keep chaining.

Wrapping your own code

val result = SupabaseResult.catching { riskyParse() }
val asyncResult = SupabaseResult.suspendCatching { fetchSomething() }

Errors

@Serializable
public data class SupabaseError(
    val message: String,
    val code: String? = null,
    val details: JsonElement? = null,
    val hint: String? = null,
    val httpStatus: Int? = null,        // HTTP status, even when the body carries no code
    val retryAfterSeconds: Long? = null, // parsed from a Retry-After header (429/503)
)

httpStatus is always captured when a response was received — so a 404 or a GoTrue error body without a machine code still categorizes correctly instead of collapsing to Unknown.

Categories

Every SupabaseError maps to a category for branching without parsing codes:

when (result.errorOrNull()?.category) {
    SupabaseErrorCategory.Conflict -> /* 409, unique/constraint */
    SupabaseErrorCategory.NotFound -> /* 404 */
    SupabaseErrorCategory.Unauthorized -> /* 401 / 403 */
    SupabaseErrorCategory.RateLimited -> /* 429 */
    SupabaseErrorCategory.Validation -> /* 400 / 422 */
    SupabaseErrorCategory.Internal -> /* 5xx */
    SupabaseErrorCategory.Network -> /* offline, DNS/TLS, timeout — no response */
    SupabaseErrorCategory.Unknown, null -> /* fallback */
}

Network is distinct from Unknown so you can show offline/retry UI when no response was received at all. Two helpers make the common branches terse:

error.isNetworkError()           // category == Network
error.category.isRetryable       // true for RateLimited, Internal, Network
 
result.onNetworkError { showOfflineBanner() }   // chainable, like onRateLimited

Predicates

Convenience checks for common cases:

error.isUniquenessViolation()
error.isForeignKeyViolation()
error.isInvalidCredentials()
error.isUserAlreadyExists()
error.isFileNotFound()

Known error codes are catalogued in SupabaseErrorCodes — grouped by service (Database, Auth, Storage, Realtime, Functions) — e.g. SupabaseErrorCodes.Auth.INVALID_CREDENTIALS or SupabaseErrorCodes.Database.UNIQUENESS_VIOLATION ("23505").

Exceptions (when you want them)

val value = result.getOrThrow() // throws SupabaseException(error) on failure