Error Handling and Typed Queries
The idea in one sentence
Section titled “The idea in one sentence”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.
Check error before trusting data
Section titled “Check error before trusting data”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.
The shape of a PostgrestError
Section titled “The shape of a PostgrestError”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 as23505for 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"]