Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/sweet-singers-beg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Refactor internal Clerk error handling functions
6 changes: 6 additions & 0 deletions .changeset/tall-games-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/shared': major
'@clerk/upgrade': minor
---

Update `ClerkAPIError.kind` value to match class name
10 changes: 4 additions & 6 deletions packages/shared/src/error.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,22 @@
export { errorToJSON, parseError, parseErrors } from './errors/parseError';

export { ClerkAPIError } from './errors/clerkApiError';
export { ClerkAPIResponseError } from './errors/clerkApiResponseError';
export { ClerkError } from './errors/clerkError';
export { ClerkAPIError, isClerkAPIError } from './errors/clerkApiError';
export { ClerkAPIResponseError, isClerkAPIResponseError } from './errors/clerkApiResponseError';
export { ClerkError, isClerkError } from './errors/clerkError';

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/clerkRuntimeError';
export { ClerkRuntimeError, isClerkRuntimeError } from './errors/clerkRuntimeError';

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

export {
is4xxError,
isCaptchaError,
isClerkAPIResponseError,
isClerkRuntimeError,
isEmailLinkError,
isKnownError,
isMetamaskError,
Expand Down
10 changes: 5 additions & 5 deletions packages/shared/src/errors/clerkApiError.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { ClerkAPIError as ClerkAPIErrorInterface, ClerkAPIErrorJSON } from '../types';
import { createErrorTypeGuard } from './createErrorTypeGuard';

export type ClerkApiErrorMeta = Record<string, unknown>;
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 {
static kind = 'ClerkApiError';
export class ClerkAPIError<Meta extends ClerkAPIErrorMeta = any> implements ClerkAPIErrorInterface {
static kind = 'ClerkAPIError';
readonly code: string;
readonly message: string;
readonly longMessage: string | undefined;
Expand Down Expand Up @@ -36,6 +36,6 @@ export class ClerkAPIError<Meta extends ClerkApiErrorMeta = any> implements Cler
}

/**
* Type guard to check if a value is a ClerkApiError instance.
* Type guard to check if a value is a ClerkAPIError instance.
*/
export const isClerkApiError = createErrorTypeGuard(ClerkAPIError);
export const isClerkAPIError = createErrorTypeGuard(ClerkAPIError);
8 changes: 4 additions & 4 deletions packages/shared/src/errors/clerkApiResponseError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,14 @@ export class ClerkAPIResponseError extends ClerkError implements ClerkAPIRespons
}

/**
* Type guard to check if an error is a ClerkApiResponseError.
* 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)) { ... }
* if (isClerkAPIResponseError(error)) { ... }
*
* // As a method (when attached to error object)
* if (error.isClerkApiResponseError()) { ... }
* if (error.isClerkAPIResponseError()) { ... }
*/
export const isClerkApiResponseError = createErrorTypeGuard(ClerkAPIResponseError);
export const isClerkAPIResponseError = createErrorTypeGuard(ClerkAPIResponseError);
8 changes: 8 additions & 0 deletions packages/shared/src/errors/createErrorTypeGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ export function createErrorTypeGuard<T extends new (...args: any[]) => Value>(
if (!target) {
throw new TypeError(`${ErrorClass.kind || ErrorClass.name} type guard requires an error object`);
}
// Use duck-typing with 'kind' property to handle cross-bundle scenarios
// where instanceof fails due to different class instances
if (ErrorClass.kind && typeof target === 'object' && target !== null && 'constructor' in target) {
const targetConstructor = (target as { constructor?: { kind?: string } }).constructor;
if (targetConstructor?.kind === ErrorClass.kind) {
return true;
}
}
Comment on lines +30 to +37
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

Critical: Duck-typing check allows non-Error objects to pass type guard.

The duck-typing check only validates constructor.kind without verifying the target is an actual Error instance. This allows any plain object with a matching constructor.kind property to pass:

const fakeError = { constructor: { kind: 'ClerkAPIError' } };
isClerkAPIError(fakeError); // returns true, but it's not an Error

This violates the type guard's promise that error is InstanceType<T> and can cause runtime errors when code expects actual Error properties/methods.

🔎 Proposed fix to validate Error instance first
 // Use duck-typing with 'kind' property to handle cross-bundle scenarios
 // where instanceof fails due to different class instances
-if (ErrorClass.kind && typeof target === 'object' && target !== null && 'constructor' in target) {
+if (ErrorClass.kind && target instanceof Error && 'constructor' in target) {
   const targetConstructor = (target as { constructor?: { kind?: string } }).constructor;
   if (targetConstructor?.kind === ErrorClass.kind) {
     return true;
   }
 }
🤖 Prompt for AI Agents
In packages/shared/src/errors/createErrorTypeGuard.ts around lines 30 to 37, the
duck-typing branch currently returns true based only on constructor.kind which
lets arbitrary objects pass as errors; update the guard to first verify the
target has the shape of a real Error (e.g., is an object, not null, has a string
'message' property and optionally a string 'name' or 'stack') before checking
target.constructor?.kind === ErrorClass.kind, and only then return true; keep
the existing cross-bundle kind check but gate it behind the Error-shape
validation so plain objects like { constructor: { kind: 'X' } } no longer pass.

return target instanceof ErrorClass;
}

Expand Down
4 changes: 2 additions & 2 deletions packages/shared/src/errors/globalHookError.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isClerkApiResponseError } from './clerkApiResponseError';
import { isClerkAPIResponseError } from './clerkApiResponseError';
import type { ClerkError } from './clerkError';
import { isClerkRuntimeError } from './clerkRuntimeError';

Expand All @@ -9,7 +9,7 @@ import { isClerkRuntimeError } from './clerkRuntimeError';
*/
export function createClerkGlobalHookError(error: ClerkError) {
const predicates = {
isClerkApiResponseError,
isClerkAPIResponseError,
isClerkRuntimeError,
} as const;

Expand Down
31 changes: 2 additions & 29 deletions packages/shared/src/errors/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ClerkAPIResponseError } from './clerkApiResponseError';
import { isClerkAPIResponseError } from './clerkApiResponseError';
import type { ClerkRuntimeError } from './clerkRuntimeError';
import { isClerkRuntimeError } from './clerkRuntimeError';
import type { EmailLinkError } from './emailLinkError';
import type { MetamaskError } from './metamaskError';

Expand Down Expand Up @@ -55,35 +57,6 @@ export function isKnownError(error: any): error is ClerkAPIResponseError | Clerk
return isClerkAPIResponseError(error) || isMetamaskError(error) || isClerkRuntimeError(error);
}

/**
* Checks if the provided error is a ClerkAPIResponseError.
*
* @internal
*/
export function isClerkAPIResponseError(err: any): err is ClerkAPIResponseError {
return err && 'clerkError' in err;
}

/**
* Checks if the provided error object is an instance of ClerkRuntimeError.
*
* @param err - The error object to check.
* @returns True if the error is a ClerkRuntimeError, false otherwise.
*
* @example
* const error = new ClerkRuntimeError('An error occurred');
* if (isClerkRuntimeError(error)) {
* // Handle ClerkRuntimeError
* console.error('ClerkRuntimeError:', error.message);
* } else {
* // Handle other errors
* console.error('Other error:', error.message);
* }
*/
export function isClerkRuntimeError(err: any): err is ClerkRuntimeError {
return 'clerkRuntimeError' in err;
}

/**
* Checks if the provided error is a Clerk runtime error indicating a reverification was cancelled.
*
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/Checkout/CheckoutPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const FetchStatus = ({
const internalFetchStatus = useMemo(() => {
if (errors.global) {
const errorCodes = errors.global.flatMap(e => {
if (e.isClerkApiResponseError()) {
if (e.isClerkAPIResponseError()) {
return e.errors.map(e => e.code);
}
});
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/Checkout/parts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const InvalidPlanScreen = () => {
const { errors } = useCheckout();

const InvalidPlanError = errors?.global
?.filter(e => e.isClerkApiResponseError())
?.filter(e => e.isClerkAPIResponseError())
.flatMap(e => e.errors)
.find(e => e.code === 'invalid_plan_change');

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
title: '`ClerkAPIError.kind` value updated'
matcher: 'ClerkApiError'
category: 'behavior-change'
warning: true
---

`ClerkAPIError.kind` has been updated to match the class name:

```diff
- static kind = 'ClerkApiError'
+ static kind = 'ClerkAPIError'
```

Most users should not be affected. If you were checking this string directly (for example, `error.constructor.kind === 'ClerkApiError'`), update the comparison value.

No other changes are required.
Loading