import { isClerkAPIResponseError, isClerkRuntimeError, isReverificationCancelledError } from '@clerk/shared/error'; import type { ClerkAPIError } from '@clerk/shared/types'; import { snakeToCamel } from '@clerk/shared/underscore'; import type { ErrorDescription, LocalizableError, MessageValues } from '../localization'; export interface FormError { global?: LocalizableError; fields?: Partial>; } export const UNEXPECTED_ERROR: LocalizableError = { code: 'generic' }; /** What a save rejects with when the failure is the user's to fix; anything else propagates. */ export class SaveError extends Error { readonly formError: FormError; constructor(formError: FormError) { super(formError.global?.message ?? 'Save failed'); this.name = 'SaveError'; this.formError = formError; } } /** Reads every error a Clerk failure carries, or `undefined` when the cause is not from Clerk. */ function toClerkErrors(cause: unknown, params?: MessageValues): LocalizableError[] | undefined { if (!(cause instanceof Error)) { return undefined; } if (isClerkRuntimeError(cause)) { return [ { code: cause.code, ...(cause.longMessage ? { message: cause.longMessage } : {}), ...(params ? { params } : {}), }, ]; } if (!isClerkAPIResponseError(cause)) { return undefined; } return cause.errors.map((error: ClerkAPIError) => { const paramName = error.meta?.paramName; return { code: error.code, ...(paramName ? { paramName } : {}), message: error.longMessage || error.message, ...(params ? { params } : {}), }; }); } function toClerkFormError( cause: unknown, fields: readonly TField[], params: MessageValues | undefined, ): FormError | undefined { const errors = toClerkErrors(cause, params); if (!errors) { return undefined; } const formError: FormError = {}; for (const error of errors) { const field = fields.find(f => error.paramName && snakeToCamel(error.paramName) === f); if (field) { formError.fields = { ...formError.fields, [field]: formError.fields?.[field] ?? error }; } else { formError.global ??= error; } } return formError; } /** * Runs a save and rejects with a `SaveError` the view can render. `fields` names the controls the * failure may be routed to; an error that names none of them lands in `global`. `params` carries * the instance settings a failure's message may need to read — the length bounds a username was * measured against, say — since only the caller's layer can resolve them. */ export async function save( run: () => Promise, fields: readonly TField[] = [], params?: MessageValues, ): Promise { try { await run(); } catch (cause) { // A cancelled reverification is the user backing out, not a failure to show, so it passes // through for the owner to recognise. if (isReverificationCancelledError(cause)) { throw cause; } const formError = toClerkFormError(cause, fields, params); if (!formError) { throw cause; } throw new SaveError(formError); } } /** * Reads what to tell the user about a failed action. A Clerk error keeps its code so the catalog can * localize it; anything else keeps its cause, is logged, and renders as the caller's fallback. */ export function toLocalizableError(cause: unknown): ErrorDescription { const error = cause instanceof SaveError ? (cause.formError.global ?? Object.values(cause.formError.fields ?? {}).find(field => field !== undefined)) : toClerkErrors(cause)?.[0]; if (error) { return error; } console.error('[Clerk] Could not localize error', cause); return { cause }; } export function toLocalizableErrors(cause: unknown): ErrorDescription[] { const errors = cause instanceof SaveError ? [cause.formError.global, ...Object.values(cause.formError.fields ?? {})].filter(error => error !== undefined) : toClerkErrors(cause); return errors?.length ? errors : [toLocalizableError(cause)]; } /** Reads what a rejected save left for the view. An unrecognized rejection is the generic error. */ export function toFormError(cause: unknown): FormError { if (cause instanceof SaveError) { const { global, fields } = cause.formError; return global || fields ? { global, fields } : { global: UNEXPECTED_ERROR }; } console.error('[Clerk] Could not localize error', cause); return { global: UNEXPECTED_ERROR }; } /** Reads the global error a rejected save left for the view. An unrecognized rejection is the generic error. */ export function toGlobalError(cause: unknown): LocalizableError | undefined { return toFormError(cause).global; }