diff --git a/.changeset/refactor-error-handling-system.md b/.changeset/refactor-error-handling-system.md new file mode 100644 index 00000000000..76c17506f1c --- /dev/null +++ b/.changeset/refactor-error-handling-system.md @@ -0,0 +1,13 @@ +--- +'@clerk/shared': minor +--- + +Internal refactor of error handling to improve type safety and error classification. + +- Introduce new `ClerkError` base class for all Clerk errors +- Rename internal error files: `apiResponseError.ts` → `clerkApiResponseError.ts`, `runtimeError.ts` → `clerkRuntimeError.ts` +- Add `ClerkAPIError` class for individual API errors with improved type safety +- Add type guard utilities (`isClerkError`, `isClerkRuntimeError`, `isClerkApiResponseError`) for better error handling +- Deprecate `clerkRuntimeError` property in favor of `clerkError` for consistency +- Add support for error codes, long messages, and documentation URLs + diff --git a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap index 8db8c3f416e..d0d845d9b2a 100644 --- a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap +++ b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap @@ -147,6 +147,7 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = ` "shared/build-clerk-js-script-attributes.mdx", "shared/build-publishable-key.mdx", "shared/camel-to-snake.mdx", + "shared/clerk-api-error.mdx", "shared/clerk-js-script-url.mdx", "shared/clerk-runtime-error.mdx", "shared/create-dev-or-staging-url-cache.mdx", diff --git a/.typedoc/__tests__/file-structure.test.ts b/.typedoc/__tests__/file-structure.test.ts index ce7ea2803c0..bb658123bc3 100644 --- a/.typedoc/__tests__/file-structure.test.ts +++ b/.typedoc/__tests__/file-structure.test.ts @@ -40,11 +40,7 @@ describe('Typedoc output', () => { ] `); }); - it('should have a deliberate file structure', async () => { - const files = await scanDirectory('file'); - expect(files).toMatchSnapshot(); - }); it('should only contain lowercase files', async () => { const files = await scanDirectory('file'); const upperCaseFiles = files.filter(file => /[A-Z]/.test(file)); diff --git a/packages/clerk-js/src/core/resources/Verification.ts b/packages/clerk-js/src/core/resources/Verification.ts index 9de4244d3f9..606ee10718c 100644 --- a/packages/clerk-js/src/core/resources/Verification.ts +++ b/packages/clerk-js/src/core/resources/Verification.ts @@ -1,6 +1,5 @@ -import { errorToJSON, parseError } from '@clerk/shared/error'; +import { ClerkAPIError, errorToJSON } from '@clerk/shared/error'; import type { - ClerkAPIError, PasskeyVerificationResource, PhoneCodeChannel, PublicKeyCredentialCreationOptionsJSON, @@ -58,7 +57,7 @@ export class Verification extends BaseResource implements VerificationResource { } this.attempts = data.attempts; this.expireAt = unixEpochToDate(data.expire_at || undefined); - this.error = data.error ? parseError(data.error) : null; + this.error = data.error ? new ClerkAPIError(data.error) : null; this.channel = data.channel || undefined; } return this; diff --git a/packages/clerk-js/src/ui/components/SignUp/__tests__/SignUpContinue.test.tsx b/packages/clerk-js/src/ui/components/SignUp/__tests__/SignUpContinue.test.tsx index 12dd36bc7ea..ca78577f8c3 100644 --- a/packages/clerk-js/src/ui/components/SignUp/__tests__/SignUpContinue.test.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/__tests__/SignUpContinue.test.tsx @@ -1,7 +1,6 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; import { waitFor } from '@testing-library/react'; -import React from 'react'; import { describe, expect, it } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; diff --git a/packages/shared/src/__tests__/error.test.ts b/packages/shared/src/__tests__/error.test.ts index 85be2d7d2df..bc282cad179 100644 --- a/packages/shared/src/__tests__/error.test.ts +++ b/packages/shared/src/__tests__/error.test.ts @@ -39,23 +39,21 @@ describe('ClerkRuntimeError', () => { it('throws the correct error message', () => { expect(() => { throw clerkRuntimeError; - }).toThrow(/^🔒 Clerk: test\n\n\(code="test_code"\)/); + }).toThrow(/^Clerk: test\n\n\(code="test_code"\)/); }); it('throws the correct error message without duplicate prefixes', () => { expect(() => { - throw new ClerkRuntimeError('🔒 Clerk: test', { code: 'test_code' }); - }).toThrow(/^🔒 Clerk: test\n\n\(code="test_code"\)/); + throw new ClerkRuntimeError('Clerk: test', { code: 'test_code' }); + }).toThrow(/^Clerk: test\n\n\(code="test_code"\)/); }); it('properties are populated correctly', () => { expect(clerkRuntimeError.name).toEqual('ClerkRuntimeError'); expect(clerkRuntimeError.code).toEqual('test_code'); - expect(clerkRuntimeError.message).toMatch(/🔒 Clerk: test\n\n\(code="test_code"\)/); + expect(clerkRuntimeError.message).toMatch(/Clerk: test\n\n\(code="test_code"\)/); expect(clerkRuntimeError.clerkRuntimeError).toBe(true); - expect(clerkRuntimeError.toString()).toMatch( - /^\[ClerkRuntimeError\]\nMessage:🔒 Clerk: test\n\n\(code="test_code"\)/, - ); + expect(clerkRuntimeError.toString()).toMatch(/^\[ClerkRuntimeError\]\nMessage:Clerk: test\n\n\(code="test_code"\)/); }); it('helper recognises error', () => { diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts index 58d6881434c..25ea370ca1c 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -1,6 +1,7 @@ export { errorToJSON, parseError, parseErrors } from './errors/parseError'; -export { ClerkAPIResponseError } from './errors/apiResponseError'; +export { ClerkAPIError } from './errors/clerkApiError'; +export { ClerkAPIResponseError } from './errors/clerkApiResponseError'; export { buildErrorThrower, type ErrorThrower, type ErrorThrowerOptions } from './errors/errorThrower'; @@ -8,7 +9,7 @@ export { EmailLinkError, EmailLinkErrorCode, EmailLinkErrorCodeStatus } from './ export type { MetamaskError } from './errors/metamaskError'; -export { ClerkRuntimeError } from './errors/runtimeError'; +export { ClerkRuntimeError } from './errors/clerkRuntimeError'; export { ClerkWebAuthnError } from './errors/webAuthNError'; diff --git a/packages/shared/src/errors/apiResponseError.ts b/packages/shared/src/errors/apiResponseError.ts deleted file mode 100644 index e3541649735..00000000000 --- a/packages/shared/src/errors/apiResponseError.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { - ClerkAPIError, - ClerkAPIErrorJSON, - ClerkAPIResponseError as ClerkAPIResponseErrorInterface, -} from '@clerk/types'; - -import { parseErrors } from './parseError'; - -interface ClerkAPIResponseOptions { - data: ClerkAPIErrorJSON[]; - status: number; - clerkTraceId?: string; - retryAfter?: number; -} - -export class ClerkAPIResponseError extends Error implements ClerkAPIResponseErrorInterface { - clerkError: true; - - status: number; - message: string; - clerkTraceId?: string; - retryAfter?: number; - - errors: ClerkAPIError[]; - - constructor(message: string, { data, status, clerkTraceId, retryAfter }: ClerkAPIResponseOptions) { - super(message); - - Object.setPrototypeOf(this, ClerkAPIResponseError.prototype); - - this.status = status; - this.message = message; - this.clerkTraceId = clerkTraceId; - this.retryAfter = retryAfter; - this.clerkError = true; - this.errors = parseErrors(data); - } - - public toString = () => { - let message = `[${this.name}]\nMessage:${this.message}\nStatus:${this.status}\nSerialized errors: ${this.errors.map( - e => JSON.stringify(e), - )}`; - - if (this.clerkTraceId) { - message += `\nClerk Trace ID: ${this.clerkTraceId}`; - } - - return message; - }; -} diff --git a/packages/shared/src/errors/clerkApiError.ts b/packages/shared/src/errors/clerkApiError.ts new file mode 100644 index 00000000000..1ed0e6783b3 --- /dev/null +++ b/packages/shared/src/errors/clerkApiError.ts @@ -0,0 +1,46 @@ +import type { ClerkAPIError as ClerkAPIErrorInterface, ClerkAPIErrorJSON } from '@clerk/types'; + +import { createErrorTypeGuard } from './createErrorTypeGuard'; + +export type ClerkApiErrorMeta = Record; + +/** + * This error contains the specific error message, code, and any additional metadata that was returned by the Clerk API. + */ +export class ClerkAPIError implements ClerkAPIErrorInterface { + readonly name = 'ClerkApiError'; + readonly code: string; + readonly message: string; + readonly longMessage: string | undefined; + readonly meta: Meta; + + constructor(json: ClerkAPIErrorJSON) { + const parsedError = this.parseJsonError(json); + this.code = parsedError.code; + this.message = parsedError.message; + this.longMessage = parsedError.longMessage; + this.meta = parsedError.meta; + } + + private parseJsonError(json: ClerkAPIErrorJSON) { + return { + code: json.code, + message: json.message, + longMessage: json.long_message, + meta: { + paramName: json.meta?.param_name, + sessionId: json.meta?.session_id, + emailAddresses: json.meta?.email_addresses, + identifiers: json.meta?.identifiers, + zxcvbn: json.meta?.zxcvbn, + plan: json.meta?.plan, + isPlanUpgradePossible: json.meta?.is_plan_upgrade_possible, + } as unknown as Meta, + }; + } +} + +/** + * Type guard to check if a value is a ClerkApiError instance. + */ +export const isClerkApiError = createErrorTypeGuard(ClerkAPIError); diff --git a/packages/shared/src/errors/clerkApiResponseError.ts b/packages/shared/src/errors/clerkApiResponseError.ts new file mode 100644 index 00000000000..1bc9eb96560 --- /dev/null +++ b/packages/shared/src/errors/clerkApiResponseError.ts @@ -0,0 +1,61 @@ +import type { ClerkAPIErrorJSON, ClerkAPIResponseError as ClerkAPIResponseErrorInterface } from '@clerk/types'; + +import { ClerkAPIError } from './clerkApiError'; +import type { ClerkErrorParams } from './clerkError'; +import { ClerkError } from './clerkError'; +import { createErrorTypeGuard } from './createErrorTypeGuard'; + +interface ClerkAPIResponseOptions extends Omit { + data: ClerkAPIErrorJSON[]; + status: number; + clerkTraceId?: string; + retryAfter?: number; +} + +export class ClerkAPIResponseError extends ClerkError implements ClerkAPIResponseErrorInterface { + static name = 'ClerkAPIResponseError'; + status: number; + clerkTraceId?: string; + retryAfter?: number; + errors: ClerkAPIError[]; + + constructor(message: string, options: ClerkAPIResponseOptions) { + const { data: errorsJson, status, clerkTraceId, retryAfter } = options; + super({ ...options, message, code: 'api_response_error' }); + Object.setPrototypeOf(this, ClerkAPIResponseError.prototype); + this.status = status; + this.clerkTraceId = clerkTraceId; + this.retryAfter = retryAfter; + this.errors = (errorsJson || []).map(e => new ClerkAPIError(e)); + } + + public toString() { + let message = `[${this.name}]\nMessage:${this.message}\nStatus:${this.status}\nSerialized errors: ${this.errors.map( + e => JSON.stringify(e), + )}`; + + if (this.clerkTraceId) { + message += `\nClerk Trace ID: ${this.clerkTraceId}`; + } + + return message; + } + + // Override formatMessage to keep it unformatted for backward compatibility + protected static override formatMessage(name: string, msg: string, _: string, __: string | undefined) { + return msg; + } +} + +/** + * Type guard to check if an error is a ClerkApiResponseError. + * Can be called as a standalone function or as a method on an error object. + * + * @example + * // As a standalone function + * if (isClerkApiResponseError(error)) { ... } + * + * // As a method (when attached to error object) + * if (error.isClerkApiResponseError()) { ... } + */ +export const isClerkApiResponseError = createErrorTypeGuard(ClerkAPIResponseError); diff --git a/packages/shared/src/errors/clerkError.ts b/packages/shared/src/errors/clerkError.ts new file mode 100644 index 00000000000..4f8abc9fe7e --- /dev/null +++ b/packages/shared/src/errors/clerkError.ts @@ -0,0 +1,83 @@ +import { createErrorTypeGuard } from './createErrorTypeGuard'; + +export interface ClerkErrorParams { + /** + * A message that describes the error. This is typically intented to be showed to the developers. + * It should not be shown to the user or parsed directly as the message contents are not guaranteed + * to be stable - use the `code` property instead. + */ + message: string; + /** + * A machine-stable code that identifies the error. + */ + code: string; + /** + * A user-friendly message that describes the error and can be displayed to the user. + * This message defaults to English but can be usually translated to the user's language + * by matching the `code` property to a localized message. + */ + longMessage?: string; + /** + * The cause of the error, typically an `Error` instance that was caught and wrapped by the Clerk error handler. + */ + cause?: Error; + /** + * A URL to the documentation for the error. + */ + docsUrl?: string; +} + +/** + * A temporary placeholder, this will eventually be replaced with a + * build-time flag that will actually perform DCE. + */ +const __DEV__ = true; + +export class ClerkError extends Error { + static name = 'ClerkError'; + readonly clerkError = true as const; + readonly code: string; + readonly longMessage: string | undefined; + readonly docsUrl: string | undefined; + readonly cause: Error | undefined; + + get name() { + return this.constructor.name; + } + + constructor(opts: ClerkErrorParams) { + super(new.target.formatMessage(new.target.name, opts.message, opts.code, opts.docsUrl), { cause: opts.cause }); + Object.setPrototypeOf(this, ClerkError.prototype); + this.code = opts.code; + this.docsUrl = opts.docsUrl; + this.longMessage = opts.longMessage; + this.cause = opts.cause; + } + + public toString() { + return `[${this.name}]\nMessage:${this.message}`; + } + + protected static formatMessage(name: string, msg: string, code: string, docsUrl: string | undefined) { + // Keeping the Clerk prefix for backward compatibility + // msg = `${name}: ${msg.trim()}\n\n(code="${code}")\n\n`; + // We can remove the Clerk prefix in the next major version + const prefix = 'Clerk:'; + const regex = new RegExp(prefix.replace(' ', '\\s*'), 'i'); + msg = msg.replace(regex, ''); + msg = `${prefix} ${msg.trim()}\n\n(code="${code}")\n\n`; + if (__DEV__ && docsUrl) { + msg += `\n\nDocs: ${docsUrl}`; + } + return msg; + } +} + +/** + * Type guard to check if a value is a ClerkError instance. + */ +export function isClerkError(val: unknown): val is ClerkError { + const typeguard = createErrorTypeGuard(ClerkError); + // Ths is the base error so we're being more defensive about the type guard + return typeguard(val) || (!!val && typeof val === 'object' && 'clerkError' in val && val.clerkError === true); +} diff --git a/packages/shared/src/errors/clerkRuntimeError.ts b/packages/shared/src/errors/clerkRuntimeError.ts new file mode 100644 index 00000000000..9e0cbb52a7c --- /dev/null +++ b/packages/shared/src/errors/clerkRuntimeError.ts @@ -0,0 +1,39 @@ +import type { ClerkErrorParams } from './clerkError'; +import { ClerkError } from './clerkError'; +import { createErrorTypeGuard } from './createErrorTypeGuard'; + +type ClerkRuntimeErrorOptions = Omit; + +/** + * Custom error class for representing Clerk runtime errors. + * + * @class ClerkRuntimeError + * + * @example + * throw new ClerkRuntimeError('An error occurred', { code: 'password_invalid' }); + */ +export class ClerkRuntimeError extends ClerkError { + static name = 'ClerkRuntimeError'; + /** + * @deprecated Use `clerkError` property instead. This property is maintained for backward compatibility. + */ + readonly clerkRuntimeError = true as const; + + constructor(message: string, options: ClerkRuntimeErrorOptions) { + super({ ...options, message }); + Object.setPrototypeOf(this, ClerkRuntimeError.prototype); + } +} + +/** + * Type guard to check if an error is a ClerkRuntimeError. + * Can be called as a standalone function or as a method on an error object. + * + * @example + * // As a standalone function + * if (isClerkRuntimeError(error)) { ... } + * + * // As a method (when attached to error object) + * if (error.isClerkRuntimeError()) { ... } + */ +export const isClerkRuntimeError = createErrorTypeGuard(ClerkRuntimeError); diff --git a/packages/shared/src/errors/createErrorTypeGuard.ts b/packages/shared/src/errors/createErrorTypeGuard.ts new file mode 100644 index 00000000000..62a8cfd48ca --- /dev/null +++ b/packages/shared/src/errors/createErrorTypeGuard.ts @@ -0,0 +1,39 @@ +/* eslint-disable jsdoc/require-jsdoc */ + +type Value = unknown; + +/** + * Creates a type guard function for any error class. + * The returned function can be called as a standalone function or as a method on an error object. + * + * @example + * ```typescript + * class MyError extends Error {} + * const isMyError = createErrorTypeGuard(MyError); + * + * // As a standalone function + * if (isMyError(error)) { ... } + * + * // As a method (when attached to error object) + * if (error.isMyError()) { ... } + * ``` + */ +export function createErrorTypeGuard Value>( + ErrorClass: T, +): { + (error: Value): error is InstanceType; + (this: Value): this is InstanceType; +} { + function typeGuard(this: Value, error?: Value): error is InstanceType { + const target = error ?? this; + if (!target) { + throw new TypeError(`${ErrorClass.name} type guard requires an error object`); + } + return target instanceof ErrorClass; + } + + return typeGuard as { + (error: Value): error is InstanceType; + (this: Value): this is InstanceType; + }; +} diff --git a/packages/shared/src/errors/helpers.ts b/packages/shared/src/errors/helpers.ts index 0a8811ac669..046270fedf9 100644 --- a/packages/shared/src/errors/helpers.ts +++ b/packages/shared/src/errors/helpers.ts @@ -1,7 +1,7 @@ -import type { ClerkAPIResponseError } from './apiResponseError'; +import type { ClerkAPIResponseError } from './clerkApiResponseError'; +import type { ClerkRuntimeError } from './clerkRuntimeError'; import type { EmailLinkError } from './emailLinkError'; import type { MetamaskError } from './metamaskError'; -import type { ClerkRuntimeError } from './runtimeError'; /** * Checks if the provided error object is an unauthorized error. diff --git a/packages/shared/src/errors/parseError.ts b/packages/shared/src/errors/parseError.ts index ee343bbbb5a..d5803f53b75 100644 --- a/packages/shared/src/errors/parseError.ts +++ b/packages/shared/src/errors/parseError.ts @@ -1,34 +1,25 @@ -import type { ClerkAPIError, ClerkAPIErrorJSON } from '@clerk/types'; +import type { ClerkAPIError as ClerkAPIErrorInterface, ClerkAPIErrorJSON } from '@clerk/types'; + +import { ClerkAPIError } from './clerkApiError'; /** * Parses an array of ClerkAPIErrorJSON objects into an array of ClerkAPIError objects. * * @internal */ -export function parseErrors(data: ClerkAPIErrorJSON[] = []): ClerkAPIError[] { - return data.length > 0 ? data.map(parseError) : []; +export function parseErrors(data: ClerkAPIErrorJSON[] = []): ClerkAPIErrorInterface[] { + return data.length > 0 ? data.map(e => new ClerkAPIError(e)) : []; } /** * Parses a ClerkAPIErrorJSON object into a ClerkAPIError object. * + * @deprecated Use `ClerkAPIError` class instead + * * @internal */ -export function parseError(error: ClerkAPIErrorJSON): ClerkAPIError { - return { - code: error.code, - message: error.message, - longMessage: error.long_message, - meta: { - paramName: error?.meta?.param_name, - sessionId: error?.meta?.session_id, - emailAddresses: error?.meta?.email_addresses, - identifiers: error?.meta?.identifiers, - zxcvbn: error?.meta?.zxcvbn, - plan: error?.meta?.plan, - isPlanUpgradePossible: error?.meta?.is_plan_upgrade_possible, - }, - }; +export function parseError(error: ClerkAPIErrorJSON): ClerkAPIErrorInterface { + return new ClerkAPIError(error); } /** diff --git a/packages/shared/src/errors/runtimeError.ts b/packages/shared/src/errors/runtimeError.ts deleted file mode 100644 index 3c341c78edf..00000000000 --- a/packages/shared/src/errors/runtimeError.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Custom error class for representing Clerk runtime errors. - * - * @class ClerkRuntimeError - * - * @example - * throw new ClerkRuntimeError('An error occurred', { code: 'password_invalid' }); - */ -export class ClerkRuntimeError extends Error { - clerkRuntimeError: true; - /** - * The error message in english, it contains a detailed description of the error. - */ - message: string; - - /** - * A unique code identifying the error, can be used for localization. - */ - code: string; - - /** - * The original error that was caught to throw an instance of ClerkRuntimeError. - */ - cause?: Error; - - constructor(message: string, { code, cause }: { code: string; cause?: Error }) { - const prefix = '🔒 Clerk:'; - const regex = new RegExp(prefix.replace(' ', '\\s*'), 'i'); - const sanitized = message.replace(regex, ''); - const _message = `${prefix} ${sanitized.trim()}\n\n(code="${code}")\n`; - super(_message); - - Object.setPrototypeOf(this, ClerkRuntimeError.prototype); - - this.cause = cause; - this.code = code; - this.message = _message; - this.clerkRuntimeError = true; - this.name = 'ClerkRuntimeError'; - } - - /** - * Returns a string representation of the error. - * - * @returns A formatted string with the error name and message. - */ - public toString = () => { - return `[${this.name}]\nMessage:${this.message}`; - }; -} diff --git a/packages/shared/src/errors/webAuthNError.ts b/packages/shared/src/errors/webAuthNError.ts index 18d67b958f8..dc6d2c00e93 100644 --- a/packages/shared/src/errors/webAuthNError.ts +++ b/packages/shared/src/errors/webAuthNError.ts @@ -1,4 +1,4 @@ -import { ClerkRuntimeError } from './runtimeError'; +import { ClerkRuntimeError } from './clerkRuntimeError'; type ClerkWebAuthnErrorCode = // Generic diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts new file mode 100644 index 00000000000..e69de29bb2d