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 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.
Check error before trusting data
Section titled “Check error before trusting data”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:
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.sqlcreate 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);Typed embedded-resource queries
Section titled “Typed embedded-resource queries”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:
// @runimport { 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 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 = nullembedded 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.
Type drift, proven in both directions
Section titled “Type drift, proven in both directions”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:
// @runimport { 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.sqlalter 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 existdata = nullA 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 aboveimport { 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 beforeif (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, proven for four real failures
Section titled “error.code, proven for four real failures”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:
// @runimport { createClient } from '@supabase/supabase-js';
const API_URL = process.env.SUPABASE_URL!;const admin = createClient(API_URL, process.env.SUPABASE_SERVICE_ROLE_KEY!);
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 = null23505 (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 30sconst RETRYABLE_STATUS_CODES = [520, 503]; // 520 = Cloudflare transient; 503 = PostgREST schema cache not yet loadedconst 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"] Reading AI-generated code
Section titled “Reading AI-generated code”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.
Mental Model
Section titled “Mental Model”{ 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.
Trade-offs
Section titled “Trade-offs”- Branching on
error.codeis 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
Databasetype 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 realPGRST116, 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/503only, 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 (a500, a write that timed out) is still the application’s own responsibility.
Common Misconceptions
Section titled “Common Misconceptions”- “If TypeScript compiles, the query is correct against the real database.” Proved false, in both directions: a stale
Databasetype compiles a now-broken query cleanly, and only a real runtime request reveals the42703. Regenerating types after every migration is what keepstsc’s opinion matching reality, not a suggestion. - “Branching on
error.messageis fine as long as you match the exact current wording.” Fragile by design:error.codeis the documented, stable contract (23505,42501,PGRST116proven above); message text is meant for humans and is not a versioned API. - “A caught
errorfrom.single()always means the row doesn’t exist.” Proved wrong by this lesson’sSpotTheBug: the sameerrorbranch also catches RLS rejections and other failures unlesserror.codeis checked specifically forPGRST116. - “Auth, Storage, and Functions errors are all just
PostgrestErrorlike everything else in this lesson.” Proved false by importing each package directly:AuthError/AuthApiError,StorageError/StorageApiError, andFunctionsError/FunctionsHttpErrorare three distinct real class hierarchies, none of themPostgrestError.