Skip to content

Commit 1236c74

Browse files
authored
feat(shared): Introduce base ClerkError and type guard helpers (#6985)
1 parent 57bbba6 commit 1236c74

File tree

18 files changed

+304
-138
lines changed

18 files changed

+304
-138
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@clerk/shared': minor
3+
---
4+
5+
Internal refactor of error handling to improve type safety and error classification.
6+
7+
- Introduce new `ClerkError` base class for all Clerk errors
8+
- Rename internal error files: `apiResponseError.ts``clerkApiResponseError.ts`, `runtimeError.ts``clerkRuntimeError.ts`
9+
- Add `ClerkAPIError` class for individual API errors with improved type safety
10+
- Add type guard utilities (`isClerkError`, `isClerkRuntimeError`, `isClerkApiResponseError`) for better error handling
11+
- Deprecate `clerkRuntimeError` property in favor of `clerkError` for consistency
12+
- Add support for error codes, long messages, and documentation URLs
13+

.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = `
147147
"shared/build-clerk-js-script-attributes.mdx",
148148
"shared/build-publishable-key.mdx",
149149
"shared/camel-to-snake.mdx",
150+
"shared/clerk-api-error.mdx",
150151
"shared/clerk-js-script-url.mdx",
151152
"shared/clerk-runtime-error.mdx",
152153
"shared/create-dev-or-staging-url-cache.mdx",

.typedoc/__tests__/file-structure.test.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,7 @@ describe('Typedoc output', () => {
4040
]
4141
`);
4242
});
43-
it('should have a deliberate file structure', async () => {
44-
const files = await scanDirectory('file');
4543

46-
expect(files).toMatchSnapshot();
47-
});
4844
it('should only contain lowercase files', async () => {
4945
const files = await scanDirectory('file');
5046
const upperCaseFiles = files.filter(file => /[A-Z]/.test(file));

packages/clerk-js/src/core/resources/Verification.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { errorToJSON, parseError } from '@clerk/shared/error';
1+
import { ClerkAPIError, errorToJSON } from '@clerk/shared/error';
22
import type {
3-
ClerkAPIError,
43
PasskeyVerificationResource,
54
PhoneCodeChannel,
65
PublicKeyCredentialCreationOptionsJSON,
@@ -58,7 +57,7 @@ export class Verification extends BaseResource implements VerificationResource {
5857
}
5958
this.attempts = data.attempts;
6059
this.expireAt = unixEpochToDate(data.expire_at || undefined);
61-
this.error = data.error ? parseError(data.error) : null;
60+
this.error = data.error ? new ClerkAPIError(data.error) : null;
6261
this.channel = data.channel || undefined;
6362
}
6463
return this;

packages/clerk-js/src/ui/components/SignUp/__tests__/SignUpContinue.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { ClerkAPIResponseError } from '@clerk/shared/error';
22
import { OAUTH_PROVIDERS } from '@clerk/shared/oauth';
33
import { waitFor } from '@testing-library/react';
4-
import React from 'react';
54
import { describe, expect, it } from 'vitest';
65

76
import { bindCreateFixtures } from '@/test/create-fixtures';

packages/shared/src/__tests__/error.test.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,23 +39,21 @@ describe('ClerkRuntimeError', () => {
3939
it('throws the correct error message', () => {
4040
expect(() => {
4141
throw clerkRuntimeError;
42-
}).toThrow(/^🔒 Clerk: test\n\n\(code="test_code"\)/);
42+
}).toThrow(/^Clerk: test\n\n\(code="test_code"\)/);
4343
});
4444

4545
it('throws the correct error message without duplicate prefixes', () => {
4646
expect(() => {
47-
throw new ClerkRuntimeError('🔒 Clerk: test', { code: 'test_code' });
48-
}).toThrow(/^🔒 Clerk: test\n\n\(code="test_code"\)/);
47+
throw new ClerkRuntimeError('Clerk: test', { code: 'test_code' });
48+
}).toThrow(/^Clerk: test\n\n\(code="test_code"\)/);
4949
});
5050

5151
it('properties are populated correctly', () => {
5252
expect(clerkRuntimeError.name).toEqual('ClerkRuntimeError');
5353
expect(clerkRuntimeError.code).toEqual('test_code');
54-
expect(clerkRuntimeError.message).toMatch(/🔒 Clerk: test\n\n\(code="test_code"\)/);
54+
expect(clerkRuntimeError.message).toMatch(/Clerk: test\n\n\(code="test_code"\)/);
5555
expect(clerkRuntimeError.clerkRuntimeError).toBe(true);
56-
expect(clerkRuntimeError.toString()).toMatch(
57-
/^\[ClerkRuntimeError\]\nMessage:🔒 Clerk: test\n\n\(code="test_code"\)/,
58-
);
56+
expect(clerkRuntimeError.toString()).toMatch(/^\[ClerkRuntimeError\]\nMessage:Clerk: test\n\n\(code="test_code"\)/);
5957
});
6058

6159
it('helper recognises error', () => {

packages/shared/src/error.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
export { errorToJSON, parseError, parseErrors } from './errors/parseError';
22

3-
export { ClerkAPIResponseError } from './errors/apiResponseError';
3+
export { ClerkAPIError } from './errors/clerkApiError';
4+
export { ClerkAPIResponseError } from './errors/clerkApiResponseError';
45

56
export { buildErrorThrower, type ErrorThrower, type ErrorThrowerOptions } from './errors/errorThrower';
67

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

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

11-
export { ClerkRuntimeError } from './errors/runtimeError';
12+
export { ClerkRuntimeError } from './errors/clerkRuntimeError';
1213

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

packages/shared/src/errors/apiResponseError.ts

Lines changed: 0 additions & 50 deletions
This file was deleted.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { ClerkAPIError as ClerkAPIErrorInterface, ClerkAPIErrorJSON } from '@clerk/types';
2+
3+
import { createErrorTypeGuard } from './createErrorTypeGuard';
4+
5+
export type ClerkApiErrorMeta = Record<string, unknown>;
6+
7+
/**
8+
* This error contains the specific error message, code, and any additional metadata that was returned by the Clerk API.
9+
*/
10+
export class ClerkAPIError<Meta extends ClerkApiErrorMeta = any> implements ClerkAPIErrorInterface {
11+
readonly name = 'ClerkApiError';
12+
readonly code: string;
13+
readonly message: string;
14+
readonly longMessage: string | undefined;
15+
readonly meta: Meta;
16+
17+
constructor(json: ClerkAPIErrorJSON) {
18+
const parsedError = this.parseJsonError(json);
19+
this.code = parsedError.code;
20+
this.message = parsedError.message;
21+
this.longMessage = parsedError.longMessage;
22+
this.meta = parsedError.meta;
23+
}
24+
25+
private parseJsonError(json: ClerkAPIErrorJSON) {
26+
return {
27+
code: json.code,
28+
message: json.message,
29+
longMessage: json.long_message,
30+
meta: {
31+
paramName: json.meta?.param_name,
32+
sessionId: json.meta?.session_id,
33+
emailAddresses: json.meta?.email_addresses,
34+
identifiers: json.meta?.identifiers,
35+
zxcvbn: json.meta?.zxcvbn,
36+
plan: json.meta?.plan,
37+
isPlanUpgradePossible: json.meta?.is_plan_upgrade_possible,
38+
} as unknown as Meta,
39+
};
40+
}
41+
}
42+
43+
/**
44+
* Type guard to check if a value is a ClerkApiError instance.
45+
*/
46+
export const isClerkApiError = createErrorTypeGuard(ClerkAPIError);
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { ClerkAPIErrorJSON, ClerkAPIResponseError as ClerkAPIResponseErrorInterface } from '@clerk/types';
2+
3+
import { ClerkAPIError } from './clerkApiError';
4+
import type { ClerkErrorParams } from './clerkError';
5+
import { ClerkError } from './clerkError';
6+
import { createErrorTypeGuard } from './createErrorTypeGuard';
7+
8+
interface ClerkAPIResponseOptions extends Omit<ClerkErrorParams, 'message' | 'code'> {
9+
data: ClerkAPIErrorJSON[];
10+
status: number;
11+
clerkTraceId?: string;
12+
retryAfter?: number;
13+
}
14+
15+
export class ClerkAPIResponseError extends ClerkError implements ClerkAPIResponseErrorInterface {
16+
static name = 'ClerkAPIResponseError';
17+
status: number;
18+
clerkTraceId?: string;
19+
retryAfter?: number;
20+
errors: ClerkAPIError[];
21+
22+
constructor(message: string, options: ClerkAPIResponseOptions) {
23+
const { data: errorsJson, status, clerkTraceId, retryAfter } = options;
24+
super({ ...options, message, code: 'api_response_error' });
25+
Object.setPrototypeOf(this, ClerkAPIResponseError.prototype);
26+
this.status = status;
27+
this.clerkTraceId = clerkTraceId;
28+
this.retryAfter = retryAfter;
29+
this.errors = (errorsJson || []).map(e => new ClerkAPIError(e));
30+
}
31+
32+
public toString() {
33+
let message = `[${this.name}]\nMessage:${this.message}\nStatus:${this.status}\nSerialized errors: ${this.errors.map(
34+
e => JSON.stringify(e),
35+
)}`;
36+
37+
if (this.clerkTraceId) {
38+
message += `\nClerk Trace ID: ${this.clerkTraceId}`;
39+
}
40+
41+
return message;
42+
}
43+
44+
// Override formatMessage to keep it unformatted for backward compatibility
45+
protected static override formatMessage(name: string, msg: string, _: string, __: string | undefined) {
46+
return msg;
47+
}
48+
}
49+
50+
/**
51+
* Type guard to check if an error is a ClerkApiResponseError.
52+
* Can be called as a standalone function or as a method on an error object.
53+
*
54+
* @example
55+
* // As a standalone function
56+
* if (isClerkApiResponseError(error)) { ... }
57+
*
58+
* // As a method (when attached to error object)
59+
* if (error.isClerkApiResponseError()) { ... }
60+
*/
61+
export const isClerkApiResponseError = createErrorTypeGuard(ClerkAPIResponseError);

0 commit comments

Comments
 (0)