Skip to content

Error Handling and Typed Queries

Every supabase-js call resolves to { data, error } instead of throwing, so the standard pattern is checking error first — once you have handled it, TypeScript narrows data for you, and if error is a PostgrestError you should branch on its stable code rather than its human-readable message.

You saw this pattern already in the previous lesson; here it is formalized. No supabase-js call throws on a failed request — it always resolves normally, with the result split between data and error:

const { data, error } = await supabase
.from('profiles')
.select('id, username')
.eq('id', userId)
.single();
if (error) {
console.error(error.message);
} else {
// TypeScript now knows data is not null here
console.log(data.username);
}

Because nothing is thrown, a failed request that you forget to check simply looks like data being null — there is no stack trace forcing you to notice. Checking error first is not just good practice, it is the only way to reliably tell success from failure, and it is also what lets TypeScript narrow data’s type: inside the else branch (or after an early return on error), data is known to be non-null.

When error is populated, it is a PostgrestError with four fields worth knowing:

  • message — a human-readable description of what went wrong.
  • code — a stable code: either a Postgres error code (such as 23505 for a unique-constraint violation) or a PostgREST-specific code.
  • details — additional context from Postgres, when available.
  • hint — a suggestion for fixing the problem, when Postgres or PostgREST can offer one.

The practical rule: match on error.code for any programmatic decision, not on error.message. Message text is meant for humans and can change wording between versions; the code is a stable contract you can safely branch on.

const { error } = await supabase
.from('profiles')
.insert({ id: userId, username: 'ada' });
if (error) {
if (error.code === '23505') {
// Postgres unique_violation — a stable code, unlike the message text
showError('That username is taken.');
} else {
showError('Something went wrong, please try again.');
}
}

Generated types catch bad queries before you run them

Section titled “Generated types catch bad queries before you run them”

Plugging the Database type from the previous lesson into createClient<Database>() means every .select(), .insert(), and .update() is checked against your actual schema at compile time:

import { createClient } from '@supabase/supabase-js';
import type { Database } from './database.types';
const supabase = createClient<Database>(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!
);
type ProfileInsert = Database['public']['Tables']['profiles']['Insert'];
async function createProfile(profile: ProfileInsert) {
const { data, error } = await supabase
.from('profiles')
.insert(profile)
.select()
.single();
if (error) {
if (error.code === '23505') {
throw new Error('That username is taken.');
}
throw error;
}
return data;
}

With this in place, selecting a column that does not exist on profiles, or calling createProfile with an object missing a required field from Insert, is a TypeScript error at build time — you find out while writing the code, not from a failed request in production.

flowchart LR
  call[".from('profiles').insert(profile)"] --> result["{ data, error }"]
  result --> check{"error present?"}
  check -->|"yes"| code{"error.code"}
  code -->|"23505"| unique["Show: username already taken"]
  code -->|"other"| generic["Show a generic failure message"]
  check -->|"no"| typed["Use typed data safely"]
Branch on error, and specifically on error.code, before trusting data
What shape does a supabase-js query result take, and how do you know it failed?
Why is matching on error.code more robust than matching on error.message?
What compile-time benefit does passing a generated Database type to createClient give insert and update calls?
What Postgres error code typically appears when an insert violates a unique constraint?