Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/refactor-error-handling-system.md
Original file line number Diff line number Diff line change
@@ -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

Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 0 additions & 4 deletions .typedoc/__tests__/file-structure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
5 changes: 2 additions & 3 deletions packages/clerk-js/src/core/resources/Verification.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { errorToJSON, parseError } from '@clerk/shared/error';
import { ClerkAPIError, errorToJSON } from '@clerk/shared/error';
import type {
ClerkAPIError,
PasskeyVerificationResource,
PhoneCodeChannel,
PublicKeyCredentialCreationOptionsJSON,
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
12 changes: 5 additions & 7 deletions packages/shared/src/__tests__/error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
5 changes: 3 additions & 2 deletions packages/shared/src/error.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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';

export { EmailLinkError, EmailLinkErrorCode, EmailLinkErrorCodeStatus } from './errors/emailLinkError';

export type { MetamaskError } from './errors/metamaskError';

export { ClerkRuntimeError } from './errors/runtimeError';
export { ClerkRuntimeError } from './errors/clerkRuntimeError';

export { ClerkWebAuthnError } from './errors/webAuthNError';

Expand Down
50 changes: 0 additions & 50 deletions packages/shared/src/errors/apiResponseError.ts

This file was deleted.

46 changes: 46 additions & 0 deletions packages/shared/src/errors/clerkApiError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { ClerkAPIError as ClerkAPIErrorInterface, ClerkAPIErrorJSON } from '@clerk/types';

import { createErrorTypeGuard } from './createErrorTypeGuard';

export type ClerkApiErrorMeta = Record<string, unknown>;

/**
* This error contains the specific error message, code, and any additional metadata that was returned by the Clerk API.
*/
export class ClerkAPIError<Meta extends ClerkApiErrorMeta = any> 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);
61 changes: 61 additions & 0 deletions packages/shared/src/errors/clerkApiResponseError.ts
Original file line number Diff line number Diff line change
@@ -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<ClerkErrorParams, 'message' | 'code'> {
data: ClerkAPIErrorJSON[];
status: number;
clerkTraceId?: string;
retryAfter?: number;
}

export class ClerkAPIResponseError extends ClerkError implements ClerkAPIResponseErrorInterface {
static name = 'ClerkAPIResponseError';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Remove static name assignment; it can throw at runtime

Assigning to Function.name is non-writable in JS and can throw. Rely on the base getter returning constructor.name.

Apply:

-  static name = 'ClerkAPIResponseError';
+  // Name resolves from constructor via base class getter.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
static name = 'ClerkAPIResponseError';
// Name resolves from constructor via base class getter.
🤖 Prompt for AI Agents
In packages/shared/src/errors/clerkApiResponseError.ts around line 16, remove
the static name = 'ClerkAPIResponseError' assignment because writing to
Function.name can throw at runtime; instead delete that line and rely on the
default Error/constructor.name behavior (or, if a stable name is required, set a
readonly instance property in the constructor) so the class no longer attempts
to assign to Function.name.

status: number;
clerkTraceId?: string;
retryAfter?: number;
errors: ClerkAPIError[];
Comment on lines +17 to +20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Make instance properties readonly for consistency with base class.

All instance properties in the base ClerkError class are readonly. These properties should follow the same pattern since they're set once in the constructor and shouldn't change afterward.

Apply this diff:

-  status: number;
-  clerkTraceId?: string;
-  retryAfter?: number;
-  errors: ClerkAPIError[];
+  readonly status: number;
+  readonly clerkTraceId?: string;
+  readonly retryAfter?: number;
+  readonly errors: ClerkAPIError[];

Based on coding guidelines.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
status: number;
clerkTraceId?: string;
retryAfter?: number;
errors: ClerkAPIError[];
readonly status: number;
readonly clerkTraceId?: string;
readonly retryAfter?: number;
readonly errors: ClerkAPIError[];
🤖 Prompt for AI Agents
In packages/shared/src/errors/clerkApiResponseError.ts around lines 17 to 20,
the instance properties status, clerkTraceId, retryAfter, and errors should be
marked readonly to match the base ClerkError class; update their declarations to
use the readonly modifier so they are set only in the constructor and cannot be
reassigned.


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);
83 changes: 83 additions & 0 deletions packages/shared/src/errors/clerkError.ts
Original file line number Diff line number Diff line change
@@ -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';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Remove static name assignment; it can throw at runtime

Assigning to Function.name is non-writable and may throw. The base getter returns constructor.name already.

-  static name = 'ClerkError';
+  // Name resolves from constructor via getter; avoid static assignment.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
static name = 'ClerkError';
// Name resolves from constructor via getter; avoid static assignment.
🤖 Prompt for AI Agents
In packages/shared/src/errors/clerkError.ts around line 37, remove the static
name = 'ClerkError' assignment because Function.name is non-writable and can
throw at runtime; simply delete that line and rely on the default
constructor.name provided by the base Error/getter, ensuring no further changes
are needed to preserve the error class identity.

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);
}
Comment on lines +79 to +83
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make the fallback type guard type-safe

Avoid property access on unknown; narrow safely.

-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);
-}
+export function isClerkError(val: unknown): val is ClerkError {
+  const typeguard = createErrorTypeGuard(ClerkError);
+  if (typeguard(val)) return true;
+  if (typeof val === 'object' && val !== null) {
+    const v = val as { clerkError?: unknown };
+    return v.clerkError === true;
+  }
+  return false;
+}

Based on coding guidelines (proper type annotations; avoid unsafe property access).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
}
export function isClerkError(val: unknown): val is ClerkError {
const typeguard = createErrorTypeGuard(ClerkError);
if (typeguard(val)) {
return true;
}
if (typeof val === 'object' && val !== null) {
const v = val as { clerkError?: unknown };
return v.clerkError === true;
}
return false;
}
🤖 Prompt for AI Agents
In packages/shared/src/errors/clerkError.ts around lines 79 to 83, the fallback
type guard accesses properties on an unknown value unsafely; change the guard to
first narrow val to a non-null object and then access the property with a typed
index (e.g. if (val && typeof val === 'object' && 'clerkError' in val) { const v
= val as Record<string, unknown>; return v['clerkError'] === true } ), combining
that check with the existing createErrorTypeGuard result so the function remains
type-safe and avoids direct property access on unknown.

39 changes: 39 additions & 0 deletions packages/shared/src/errors/clerkRuntimeError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { ClerkErrorParams } from './clerkError';
import { ClerkError } from './clerkError';
import { createErrorTypeGuard } from './createErrorTypeGuard';

type ClerkRuntimeErrorOptions = Omit<ClerkErrorParams, 'message'>;

/**
* 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';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Remove static name assignment; Function.name is non‑writable and can throw

Rely on the base getter using constructor.name. Avoid assigning to Function.name in strict mode.

-  static name = 'ClerkRuntimeError';
+  // Name resolves from constructor via base class getter.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
static name = 'ClerkRuntimeError';
// Name resolves from constructor via base class getter.
🤖 Prompt for AI Agents
In packages/shared/src/errors/clerkRuntimeError.ts around line 16, remove the
static name = 'ClerkRuntimeError'; assignment because Function.name is
non-writable in strict mode; rely on the base error class/getter that uses
constructor.name instead and delete this assignment (and any tests or code that
expect a writable Function.name) so the runtime won't throw when the class is
evaluated.

/**
* @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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Avoid duplicate guards; ensure a single exported isClerkRuntimeError

There’s another helper with the same name relying on a marker prop (helpers.ts snippet). Having two exports with the same name can cause conflicts or confusion. Pick one (prefer the factory instanceof guard) and deprecate the other, or re‑export only one from the public surface.

Run to locate duplicates and re‑exports:


🏁 Script executed:

#!/bin/bash
# Find all definitions/exports named isClerkRuntimeError
rg -nP '\b(export\s+(const|function)\s+isClerkRuntimeError\b|export\s*\{[^\}]*\bisClerkRuntimeError\b[^\}]*\})' -C2

# Check aggregator exports
rg -nP 'from\s+["'\''][\.\/].*error' -C2 packages/shared/src/error.ts || true
rg -nP '\bisClerkRuntimeError\b' -C3 packages/shared/src/error.ts || true

Length of output: 1500


Eliminate duplicate isClerkRuntimeError guards
Drop the marker-prop guard in packages/shared/src/errors/helpers.ts (and its re-export in packages/shared/src/error.ts), exposing only the factory-based guard in packages/shared/src/errors/clerkRuntimeError.ts.

🤖 Prompt for AI Agents
In packages/shared/src/errors/clerkRuntimeError.ts around line 39, keep the
factory-based guard export (export const isClerkRuntimeError =
createErrorTypeGuard(ClerkRuntimeError)); and remove the duplicate marker-prop
guard: delete the marker-based isClerkRuntimeError from
packages/shared/src/errors/helpers.ts and remove its re-export from
packages/shared/src/error.ts; update any imports across the codebase that
referenced the helpers' or error.ts re-export to import the factory-based guard
from packages/shared/src/errors/clerkRuntimeError.ts instead, ensuring only the
factory-based implementation is exported and used.

Loading
Loading