Twenty contacts were supposed to be archived in a CRM, yet the UI flashed a green success badge and the operation silently did nothing. A TypeScript helper that makes the error field mandatory in Supabase-JS mutations now forces developers to confront that failure instead of sweeping it under the rug.

Why Supabase-JS’s return shape is a trap

Supabase’s JavaScript client (@supabase/supabase-js) does not throw exceptions when a database write is rejected. Instead it resolves the promise with an object { data, error }. If a row-level security (RLS) policy, a unique-key violation, or any other constraint blocks the query, data comes back as null and error contains the database message. The client assumes the caller will inspect error; it never aborts the call.

In practice many codebases treat the call as a fire-and-forget operation:

await supabase
  .from('contacts')
  .update({ statut: 'ancien', archived_at: now })
  .eq('id', id)

return { ok: true }

When the update is blocked by an RLS rule, the promise still resolves. Because the developer never destructures { error }, the failure is invisible. The function returns { ok: true }, the UI shows success, and the data remains unchanged. No logs appear, no Sentry alert fires, and the bug can sit for days.

A linter isn’t enough

Static analysis tools can warn when an error property is ignored, but they cannot enforce a runtime contract. A developer can still write const _ = await … and silence the warning, or they can add a comment to suppress the rule. The underlying problem is that the type system allows a call to succeed without ever mentioning error.

The mutate() helper: making error handling compulsory

The author built a small wrapper called mutate() that changes the shape of the return type. Instead of { data, error }, the helper returns a tuple [data, error] where error is a required field. TypeScript then refuses to compile any call that discards the second element.

async function mutate<T>(promise: Promise<{ data: T | null; error: any }>) {
  const { data, error } = await promise
  // Send error to Sentry immediately
  if (error) Sentry.captureException(error)
  return [data, error] as const
}

Usage becomes explicit:

const [result, err] = await mutate(
  supabase
    .from('contacts')
    .update({ statut: 'ancien', archived_at: now })
    .eq('id', id)
)

if (err) {
  // Handle or rethrow
  return { ok: false, message: err.message }
}
return { ok: true, data: result }

If a developer forgets to capture err, TypeScript emits an error: “Tuple type [T, any] of length 2 has no element at index 1.” The code will not compile until the error is addressed. The helper also injects Sentry instrumentation, guaranteeing that every database rejection is logged even if the caller later swallows it.

What’s at stake

  • Data integrity – Silent failures let invalid state creep into production. An archived contact that never left the active list can cause downstream reports to be wrong.
  • User trust – A UI that claims success while nothing changed erodes confidence. Customers see “archived” but still find the contact in searches.
  • Developer time – Chasing phantom bugs consumes hours. Explicit error handling surfaces the problem at the point of failure, shortening the debugging loop.
  • Operational cost – Adding a few extra lines of code and a small wrapper is negligible compared with the cost of a silent data loss incident.

The trade-off

The helper adds verbosity: every mutation call now returns a tuple, and callers must write an if (err) block. Some teams may view this as boilerplate noise, especially for simple CRUD actions where they expect success. The counter-argument is that the extra code is a safeguard, not an optional feature. In environments where data correctness is paramount—CRM systems, finance, health—forcing the check pays for itself.

What’s next

The author promises a third installment that examines RLS policies returning zero rows without explanation. That pattern, like the silent-error case, hides failures behind an apparently successful query. Together, the series aims to expose the “rumor” side of Supabase’s API and give developers concrete tools to demand facts.

Takeaway: Supabase-JS’s design lets a failed write look like a success unless you remember to read the error field. By wrapping calls in a TypeScript-enforced helper that makes error mandatory and logs it to Sentry, you turn silent failures into visible, actionable events. The modest increase in code size buys you data reliability and user trust—two things no silent success can ever deliver.