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 a generated Database type only ever describes the schema at the moment it was generated, which this lesson proves can silently drift out of sync with the real, live database.

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.

supabase gen types typescript --local, real output

Section titled “supabase gen types typescript --local, real output”

Against this course’s local schema, trimmed to the two tables this lesson uses:

scripts/cli-gen-types.sh
supabase gen types typescript --workdir tools/probe --local
cli_widgets: {
Row: {
created_at: string
id: string
owner: string
price: number
title: string
}
Insert: {
created_at?: string
id?: string
owner?: string
price: number
title: string
}
Update: {
created_at?: string
id?: string
owner?: string
price?: number
title?: string
}
Relationships: []
}
cli_widget_notes: {
Row: {
id: string
note: string
widget_id: string
}
...
Relationships: [
{
foreignKeyName: "cli_widget_notes_widget_id_fkey"
columns: ["widget_id"]
referencedRelation: "cli_widgets"
referencedColumns: ["id"]
}
]
}

Real, generated straight from the live schema behind this lesson: create table public.cli_widgets (id uuid primary key default gen_random_uuid(), owner uuid not null default auth.uid() references auth.users(id), title text not null unique, price integer not null, created_at timestamptz not null default now()), plus a child cli_widget_notes table with a foreign key back to it — the Relationships array is how the generator records that foreign key, and it is what makes the embedded-resource query below type-safe.

-- supabase/migrations/20260929000000_cli_widgets.sql
create table public.cli_widgets (
id uuid primary key default gen_random_uuid(),
owner uuid not null default auth.uid() references auth.users(id),
title text not null unique,
price integer not null,
created_at timestamptz not null default now()
);
alter table public.cli_widgets enable row level security;
create policy "cli_widgets_select_all" on public.cli_widgets for select to anon, authenticated using (true);
create policy "cli_widgets_insert_own" on public.cli_widgets for insert to authenticated with check (owner = auth.uid());
create table public.cli_widget_notes (
id uuid primary key default gen_random_uuid(),
widget_id uuid not null references public.cli_widgets(id) on delete cascade,
note text not null
);
alter table public.cli_widget_notes enable row level security;
create policy "cli_widget_notes_select_all" on public.cli_widget_notes for select to anon, authenticated using (true);
create policy "cli_widget_notes_insert_authenticated" on public.cli_widget_notes for insert to authenticated with check (true);

createClient<Database>() (the previous lesson) checks every .from() call against the generated Database type — including a select() string that embeds a related table by name, which PostgREST resolves via the foreign key above and the query builder types as a nested array automatically:

src/cli-widgets-embedded-select.ts
// @run
import { createClient } from '@supabase/supabase-js';
const API_URL = process.env.SUPABASE_URL!;
const ANON_KEY = process.env.SUPABASE_ANON_KEY!;
const admin = createClient(API_URL, process.env.SUPABASE_SERVICE_ROLE_KEY!);
const email = '[email protected]';
const password = 'cli-pw-widgets-12345';
let user = (await admin.auth.admin.listUsers()).data.users.find((u) => u.email === email);
if (!user) user = (await admin.auth.admin.createUser({ email, password, email_confirm: true })).data.user!;
const client = createClient(API_URL, ANON_KEY);
await client.auth.signInWithPassword({ email, password });
await admin.from('cli_widgets').delete().eq('title', 'cli-widget-alpha'); // idempotent re-run
const { data: widget } = await client.from('cli_widgets').insert({ title: 'cli-widget-alpha', price_usd: 100 }).select().single();
await client.from('cli_widget_notes').insert({ widget_id: widget!.id, note: 'first note' });
await client.from('cli_widget_notes').insert({ widget_id: widget!.id, note: 'second note' });
const { data: embedded, error } = await client
.from('cli_widgets')
.select('title, price_usd, cli_widget_notes(note)') // Tables<'cli_widget_notes'>[] inferred via the foreign key
.eq('id', widget!.id)
.single();
console.log('embedded select error =', error?.code ?? null);
console.log('embedded select data =', JSON.stringify(embedded));
await admin.from('cli_widgets').delete().eq('title', 'cli-widget-alpha');
embedded select error = null
embedded select data = {"title":"cli-widget-alpha","price_usd":100,"cli_widget_notes":[{"note":"first note"},{"note":"second note"}]}

A note on ordering: this lesson’s own migrations run in document order, so by the time any of its // @run scripts actually execute, the rename in the next section has already happened for real — every runnable proof from here on targets the live column’s real name, price_usd. The next section’s “stale type” proof deliberately keeps using the old price name in a hand-written type, on purpose, as a stand-in for a database.types.ts file nobody regenerated — that mismatch, not this section’s naming, is the entire point of what comes next.

embedded.cli_widget_notes is inferred as { note: string }[] — an array, because the foreign key runs the other direction (many notes reference one widget) — with zero manual typing. Reverse the embed (select from cli_widget_notes and embed cli_widgets(...)) and the inferred shape becomes a single nested object instead of an array, following the same foreign key in the opposite direction.

A generated Database type is a snapshot. Nothing re-checks it against the live schema automatically — proving both halves of what happens when a column is renamed and the types are not regenerated:

Half 1: the stale type still compiles clean. A hand-kept snapshot of Database['public']['Tables']['cli_widgets'] exactly as gen types produced it above, before the column rename below:

src/cli-widgets-stale-type.ts
// @run
import { createClient } from '@supabase/supabase-js';
type StaleWidgetsRow = { id: string; owner: string; title: string; price: number; created_at: string };
type StaleDatabase = {
public: { Tables: { cli_widgets: { Row: StaleWidgetsRow; Insert: Partial<StaleWidgetsRow>; Update: Partial<StaleWidgetsRow> } } };
};
const stale = createClient<StaleDatabase>(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
const { data, error } = await stale.from('cli_widgets').select('id, title, price');
console.log('compiles clean, but runtime error.code =', error?.code, '| message =', error?.message);
console.log('data =', data);

Compiling this file with tsc alone (node_modules/.bin/tsc -p . — the same shared program this course’s harness type-checks every src/*.ts fence with) reports zero errors for it. But the migration below renames price to price_usd for real:

-- supabase/migrations/20260929000001_cli_widgets_rename_price.sql
alter table public.cli_widgets rename column price to price_usd;

Running the exact same, cleanly-compiled script against the now-renamed live table:

compiles clean, but runtime error.code = 42703 | message = column cli_widgets.price does not exist
data = null

A real Postgres 42703 (undefined_column), surfaced straight through PostgREST — tsc found nothing wrong, and the query still failed. The stale type described a schema that no longer exists.

Half 2: regenerating types turns the same mistake into a compile error, before anything runs. Re-running supabase gen types typescript --local now reports price_usd, not price. Writing the exact same query and property access against this fresh type:

// @expect-error TS2339: Property 'price' does not exist on type 'never' — the regenerated type catches the same stale-column mistake proved above
import { createClient } from '@supabase/supabase-js';
type FreshWidgetsRow = { id: string; owner: string; title: string; price_usd: number; created_at: string };
type FreshDatabase = {
public: { Tables: { cli_widgets: { Row: FreshWidgetsRow; Insert: Partial<FreshWidgetsRow>; Update: Partial<FreshWidgetsRow> } } };
};
const fresh = createClient<FreshDatabase>('http://x', 'x');
const { data, error } = await fresh.from('cli_widgets').select('id, title, price').single(); // same typo as before
if (error) throw error;
console.log(data.price);
error TS2339: Property 'price' does not exist on type 'never'.

A real tsc diagnostic — worth being precise about what actually gets checked here: postgrest-js’s .select('...') string is not itself validated against the schema at the type level in this version, but the shape of the data it returns is derived from it, and that shape is what any property access is checked against — price was never part of cli_widgets’ Row under the fresh type, so data.price resolves to a compile error (never, since the builder could not match any column named price in the select string against the fresh Row type) instead of a silent undefined at runtime. This is the concrete version of “regenerate your types after every migration”: skipping it does not make TypeScript catch the mistake later — it makes TypeScript unable to catch it at all, and the previous proof shows exactly what “later” looks like: a real 42703 in production.

error.code is a stable identifier — either a real Postgres error code or a PostgREST-specific one — and is what code should branch on, never error.message text. Four real values, from four real requests against cli_widgets:

src/cli-widgets-error-codes.ts
// @run
import { createClient } from '@supabase/supabase-js';
const API_URL = process.env.SUPABASE_URL!;
const admin = createClient(API_URL, process.env.SUPABASE_SERVICE_ROLE_KEY!);
const email = '[email protected]';
const password = 'cli-pw-widgets-errors-12345';
let user = (await admin.auth.admin.listUsers()).data.users.find((u) => u.email === email);
if (!user) user = (await admin.auth.admin.createUser({ email, password, email_confirm: true })).data.user!;
const client = createClient(API_URL, process.env.SUPABASE_ANON_KEY!);
await client.auth.signInWithPassword({ email, password });
await admin.from('cli_widgets').delete().eq('title', 'cli-widget-errors-demo'); // idempotent re-run
await client.from('cli_widgets').insert({ title: 'cli-widget-errors-demo', price_usd: 50 });
const { error: dupErr } = await client.from('cli_widgets').insert({ title: 'cli-widget-errors-demo', price_usd: 1 });
console.log('duplicate insert -> error.code =', dupErr?.code, '|', dupErr?.message);
const otherUid = crypto.randomUUID();
const { error: rlsErr } = await client.from('cli_widgets').insert({ title: 'cli-widget-errors-spoof', price_usd: 1, owner: otherUid });
console.log('spoofed-owner insert -> error.code =', rlsErr?.code, '|', rlsErr?.message);
const { error: zeroErr } = await client.from('cli_widgets').select().eq('title', 'cli-widget-does-not-exist').single();
console.log('.single() with 0 rows -> error.code =', zeroErr?.code, '|', zeroErr?.message);
const { data: maybeData, error: maybeErr } = await client.from('cli_widgets').select().eq('title', 'cli-widget-does-not-exist').maybeSingle();
console.log('.maybeSingle() with 0 rows -> error =', maybeErr, '| data =', maybeData);
await admin.from('cli_widgets').delete().eq('title', 'cli-widget-errors-demo');
duplicate insert -> error.code = 23505 | duplicate key value violates unique constraint "cli_widgets_title_key"
spoofed-owner insert -> error.code = 42501 | new row violates row-level security policy for table "cli_widgets"
.single() with 0 rows -> error.code = PGRST116 | Cannot coerce the result to a single JSON object
.maybeSingle() with 0 rows -> error = null | data = null

23505 (unique_violation) and 42501 (insufficient_privilege, the code Postgres uses for an RLS policy rejection) are both plain Postgres error codes, passed through unchanged. PGRST116 is PostgREST’s own code, specific to .single(): it demands exactly one row and turns anything else — zero rows, or more than one — into this error. .maybeSingle() exists precisely for the “zero rows is a valid, expected outcome” case: same one-row expectation, but zero rows resolves to { data: null, error: null } instead of an error, while more than one row is still an error under either method.

if (error) {
if (error.code === '23505') {
showError('That title is taken.');
} else if (error.code === '42501') {
showError('You do not have permission to do that.');
} else if (error.code === 'PGRST116') {
showError('Not found.');
} else {
showError('Something went wrong, please try again.');
}
}

AuthError, StorageError, and FunctionsHttpError: the real class names

Section titled “AuthError, StorageError, and FunctionsHttpError: the real class names”

Three different sub-clients throw or return three different error base classes — confirmed here by importing directly from each installed package (@supabase/[email protected], @supabase/[email protected], @supabase/[email protected]), not assumed from memory:

node -e "console.log(Object.keys(require('@supabase/auth-js')).filter(k => /Error/.test(k)))"
[ 'AuthError', 'AuthApiError', 'AuthUnknownError', 'AuthSessionMissingError',
'AuthInvalidTokenResponseError', 'AuthInvalidCredentialsError', 'AuthWeakPasswordError',
'AuthInvalidJwtError', 'AuthRetryableFetchError', ... ]
node -e "console.log(Object.keys(require('@supabase/storage-js')).filter(k => /Error/.test(k)))"
[ 'StorageApiError', 'StorageError', 'StorageUnknownError', 'StorageVectorsApiError', ... ]
node -e "console.log(Object.keys(require('@supabase/functions-js')).filter(k => /Error/.test(k)))"
[ 'FunctionsError', 'FunctionsFetchError', 'FunctionsHttpError', 'FunctionsRelayError' ]

supabase.auth.* calls reject/return an AuthError (concretely, usually an AuthApiError); supabase.storage.* calls return a StorageError (concretely a StorageApiError); supabase.functions.invoke() rejects with a FunctionsHttpError for a non-2xx response specifically (distinct from FunctionsFetchError for a network-level failure, and FunctionsRelayError for the Functions gateway itself failing to relay the request) — three genuinely different shapes for three genuinely different services, none of them the PostgrestError this lesson otherwise focuses on. Branching on error instanceof FunctionsHttpError (rather than duck-typing on .message) is the reliable way to tell “the function ran and returned an error response” apart from “the function could not be reached at all.”

Retry strategy: what actually retries, and what never does

Section titled “Retry strategy: what actually retries, and what never does”

Reading the installed @supabase/[email protected] source directly, rather than assuming: a real, built-in retry mechanism exists, and it is narrower than it might sound.

# tools/probe/node_modules/@supabase/postgrest-js/dist/index.cjs (installed 2.117.2, read directly)
const DEFAULT_MAX_RETRIES = 3;
const getRetryDelay = (attemptIndex) => Math.min(1e3 * 2 ** attemptIndex, 3e4); // 1s, 2s, 4s, ... capped at 30s
const RETRYABLE_STATUS_CODES = [520, 503]; // 520 = Cloudflare transient; 503 = PostgREST schema cache not yet loaded
const RETRYABLE_METHODS = ["GET", "HEAD", "OPTIONS"];

Three things worth being precise about, all read directly from that source: retries are capped at 3 attempts with exponential backoff (1s, 2s, 4s, capped at 30s); only two specific transient status codes trigger a retry, not every 5xx; and only idempotent read methods (GET/HEAD/OPTIONS) ever retry automatically — a .insert()/.update()/.delete() (POST/PATCH/DELETE) is never retried automatically by this layer, because retrying a write that may have already succeeded risks a duplicate side effect. Two other services in this course handle retries completely differently: Realtime reconnects its WebSocket automatically on a CHANNEL_ERROR (configurable via realtime: { params: { reconnectAfterMs } } }, per the SDK’s own current reference examples), and Storage’s own error-codes reference documents a 503 SlowDown response as a signal to back off and retry yourself — there is no automatic retry built into the Storage client for it. The one rule that holds everywhere: never build your own blanket retry loop around a write call without an idempotency key or an equivalent guard, for the exact reason the REST layer’s own built-in retry logic excludes writes by design.

flowchart LR
  call[".from('cli_widgets').insert(row)"] --> result["{ data, error }"]
  result --> check{"error present?"}
  check -->|"yes"| code{"error.code"}
  code -->|"23505"| unique["Unique violation"]
  code -->|"42501"| rls["RLS rejection"]
  code -->|"PGRST116"| single["single() got 0 or 2+ rows"]
  check -->|"no"| typed["Use typed data safely"]
  drift[("Schema changes")] -->|"gen types NOT re-run"| stale["tsc passes, runtime 42703"]
  drift -->|"gen types re-run"| caught["tsc catches it, no runtime surprise"]
Check error, branch on error.code, and keep generated types honest

An AI assistant asked to “handle the case where a widget doesn’t exist” produced this:

🐛 Spot the bug

async function getWidgetByTitle(title: string) {
const { data, error } = await supabase
.from('cli_widgets')
.select('id, title, price_usd')
.eq('title', title)
.single();
if (error) {
// "not found" is the only realistic error here, right?
return null;
}
return data;
}
Show the answer

This treats every possible error from .single() as “not found” and silently swallows it into a null return — but .single()’s real error code for “0 or 2+ rows”, proven above, is specifically PGRST116, and this function collapses that together with a real RLS rejection (42501, also proven above — different row, wrong owner, or a missing select policy entirely) or a genuine transient failure. A caller of getWidgetByTitle cannot tell “this title really does not exist” apart from “you are not allowed to see this” or “the database is unreachable right now” — three completely different situations that call for three completely different UI responses (a 404 page, a permission error, a retry button), all flattened into the same null. The fix is checking error.code explicitly, exactly as this lesson does: if (error) { if (error.code === 'PGRST116') return null; throw error; } — now only the real “no matching row” case returns null, and everything else propagates as a real error instead of silently vanishing. This is also a case for .maybeSingle() instead of .single() in the first place: it makes “zero rows is fine” the expected, error-free path, and leaves error meaning what it should — something actually went wrong.

{ data, error } is not two independent fields — it is one discriminated union wearing two-fields clothing: exactly one of them is meaningfully non-null on any given call, and every bug in this lesson comes from code that forgets that and reads data (or a property on it) without having actually ruled out error first, whether by a missing check (the SpotTheBug) or a type that no longer matches reality (the drift proof). A generated Database type is not a live connection to your schema — it is a snapshot, a plain TypeScript file that happens to have been produced by inspecting the schema once. tsc is completely honest about the file it was given; it has no way to know that file is stale, which is exactly why the drift proof compiles clean on the old type and only fails once a fresh generation actually changes what tsc is checking against. Treat gen types the same way you would treat re-running a linter after changing its config — a step the migration itself does not do for you.

  • Branching on error.code is stable across message-wording changes and locales — at the cost of needing to actually know and look up the codes (23505, 42501, PGRST116, and the rest), rather than reading an English sentence.
  • A generated Database type gives real compile-time safety for the vast majority of a schema’s lifetime — at the cost of a real gap, proven above, in the window between a migration landing and someone remembering to regenerate types; that gap is silent, not a build failure.
  • .single()’s strict “exactly one row” contract makes an accidental multi-row match loud (a real PGRST116, not a silently-picked first row) — at the cost of also making an expected, valid “zero rows” case throw, which is exactly the case .maybeSingle() exists to handle instead.
  • postgrest-js’s built-in retry (proved above: 3 attempts, exponential backoff, 520/503 only, reads only) needs zero setup for the transient failures it covers — at the cost of covering a narrow slice of real-world failures; anything else (a 500, a write that timed out) is still the application’s own responsibility.
  • “If TypeScript compiles, the query is correct against the real database.” Proved false, in both directions: a stale Database type compiles a now-broken query cleanly, and only a real runtime request reveals the 42703. Regenerating types after every migration is what keeps tsc’s opinion matching reality, not a suggestion.
  • “Branching on error.message is fine as long as you match the exact current wording.” Fragile by design: error.code is the documented, stable contract (23505, 42501, PGRST116 proven above); message text is meant for humans and is not a versioned API.
  • “A caught error from .single() always means the row doesn’t exist.” Proved wrong by this lesson’s SpotTheBug: the same error branch also catches RLS rejections and other failures unless error.code is checked specifically for PGRST116.
  • “Auth, Storage, and Functions errors are all just PostgrestError like everything else in this lesson.” Proved false by importing each package directly: AuthError/AuthApiError, StorageError/StorageApiError, and FunctionsError/FunctionsHttpError are three distinct real class hierarchies, none of them PostgrestError.
A column is renamed in a real migration, but supabase gen types is never re-run. A query written against the OLD (now stale) generated type is compiled and then executed. What actually happens?
After regenerating the Database type to match the renamed column, what happens when the exact same (now-wrong) query is compiled again?
What real error.code values were proven for a duplicate-title insert, a spoofed-owner insert under RLS, and .single() matching zero rows, respectively?
A function is called via supabase.functions.invoke() and returns a non-2xx response. What real error class does this reject with, distinct from a network-level failure?
Reading the installed postgrest-js source directly, which requests does its built-in retry logic actually cover?