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
| Function | Returns | Notes |
|---|---|---|
getOrNull() | T? | null on failure |
getOrThrow() | T | throws SupabaseException on failure |
getOrElse { error -> … } | T | compute a fallback from the error |
errorOrNull() | SupabaseError? | null on success |
fold(onSuccess, onFailure) | R | collapse both branches to one value |
toKotlinResult() | kotlin.Result<T> | interop with stdlib |
Transforming & chaining
| Function | Purpose |
|---|---|
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 onRateLimitedPredicates
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