diff --git a/.changeset/moody-parks-scream.md b/.changeset/moody-parks-scream.md new file mode 100644 index 00000000000..15f44a5b8d5 --- /dev/null +++ b/.changeset/moody-parks-scream.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +[Experimental] Add types for errors used in new custom flow APIs diff --git a/packages/clerk-js/src/core/events.ts b/packages/clerk-js/src/core/events.ts index a4071653504..da4a28fdad7 100644 --- a/packages/clerk-js/src/core/events.ts +++ b/packages/clerk-js/src/core/events.ts @@ -1,3 +1,4 @@ +import type { ClerkError } from '@clerk/shared/error'; import { createEventBus } from '@clerk/shared/eventBus'; import type { TokenResource } from '@clerk/shared/types'; @@ -15,7 +16,7 @@ export const events = { type TokenUpdatePayload = { token: TokenResource | null }; export type ResourceUpdatePayload = { resource: BaseResource }; -export type ResourceErrorPayload = { resource: BaseResource; error: unknown }; +export type ResourceErrorPayload = { resource: BaseResource; error: ClerkError | null }; export type ResourceFetchPayload = { resource: BaseResource; status: 'idle' | 'fetching' }; type InternalEvents = { diff --git a/packages/clerk-js/src/core/signals.ts b/packages/clerk-js/src/core/signals.ts index 1228e105fe2..f8b238d3aa4 100644 --- a/packages/clerk-js/src/core/signals.ts +++ b/packages/clerk-js/src/core/signals.ts @@ -1,4 +1,4 @@ -import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { type ClerkError, createClerkGlobalHookError, isClerkAPIResponseError } from '@clerk/shared/error'; import type { Errors, SignInSignal, SignUpSignal } from '@clerk/shared/types'; import { snakeToCamel } from '@clerk/shared/underscore'; import { computed, signal } from 'alien-signals'; @@ -7,7 +7,7 @@ import type { SignIn } from './resources/SignIn'; import type { SignUp } from './resources/SignUp'; export const signInResourceSignal = signal<{ resource: SignIn | null }>({ resource: null }); -export const signInErrorSignal = signal<{ error: unknown }>({ error: null }); +export const signInErrorSignal = signal<{ error: ClerkError | null }>({ error: null }); export const signInFetchSignal = signal<{ status: 'idle' | 'fetching' }>({ status: 'idle' }); export const signInComputedSignal: SignInSignal = computed(() => { @@ -21,7 +21,7 @@ export const signInComputedSignal: SignInSignal = computed(() => { }); export const signUpResourceSignal = signal<{ resource: SignUp | null }>({ resource: null }); -export const signUpErrorSignal = signal<{ error: unknown }>({ error: null }); +export const signUpErrorSignal = signal<{ error: ClerkError | null }>({ error: null }); export const signUpFetchSignal = signal<{ status: 'idle' | 'fetching' }>({ status: 'idle' }); export const signUpComputedSignal: SignUpSignal = computed(() => { @@ -38,7 +38,7 @@ export const signUpComputedSignal: SignUpSignal = computed(() => { * Converts an error to a parsed errors object that reports the specific fields that the error pertains to. Will put * generic non-API errors into the global array. */ -function errorsToParsedErrors(error: unknown): Errors { +function errorsToParsedErrors(error: ClerkError | null): Errors { const parsedErrors: Errors = { fields: { firstName: null, @@ -62,29 +62,32 @@ function errorsToParsedErrors(error: unknown): Errors { if (!isClerkAPIResponseError(error)) { parsedErrors.raw = [error]; - parsedErrors.global = [error]; + parsedErrors.global = [createClerkGlobalHookError(error)]; return parsedErrors; } - error.errors.forEach(error => { - if (parsedErrors.raw) { - parsedErrors.raw.push(error); - } else { - parsedErrors.raw = [error]; - } - - if ('meta' in error && error.meta && 'paramName' in error.meta) { - const name = snakeToCamel(error.meta.paramName); - parsedErrors.fields[name as keyof typeof parsedErrors.fields] = error; - return; - } - - if (parsedErrors.global) { - parsedErrors.global.push(error); - } else { - parsedErrors.global = [error]; - } - }); + const hasFieldErrors = error.errors.some(error => 'meta' in error && error.meta && 'paramName' in error.meta); + if (hasFieldErrors) { + error.errors.forEach(error => { + if (parsedErrors.raw) { + parsedErrors.raw.push(error); + } else { + parsedErrors.raw = [error]; + } + if ('meta' in error && error.meta && 'paramName' in error.meta) { + const name = snakeToCamel(error.meta.paramName); + parsedErrors.fields[name as keyof typeof parsedErrors.fields] = error; + } + // Note that this assumes a given ClerkAPIResponseError will only have either field errors or global errors, but + // not both. If a global error is present, it will be discarded. + }); + + return parsedErrors; + } + + // At this point, we know that `error` is a ClerkAPIResponseError and that it has no field errors. + parsedErrors.raw = [error]; + parsedErrors.global = [createClerkGlobalHookError(error)]; return parsedErrors; } diff --git a/packages/clerk-js/src/core/state.ts b/packages/clerk-js/src/core/state.ts index 82fed3485ac..2fea2b45820 100644 --- a/packages/clerk-js/src/core/state.ts +++ b/packages/clerk-js/src/core/state.ts @@ -1,3 +1,4 @@ +import type { ClerkError } from '@clerk/shared/error'; import type { State as StateInterface } from '@clerk/shared/types'; import { computed, effect } from 'alien-signals'; @@ -36,7 +37,7 @@ export class State implements StateInterface { eventBus.on('resource:fetch', this.onResourceFetch); } - private onResourceError = (payload: { resource: BaseResource; error: unknown }) => { + private onResourceError = (payload: { resource: BaseResource; error: ClerkError | null }) => { if (payload.resource instanceof SignIn) { this.signInErrorSignal({ error: payload.error }); } diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts index 25ea370ca1c..14b32870d1f 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -2,6 +2,7 @@ export { errorToJSON, parseError, parseErrors } from './errors/parseError'; export { ClerkAPIError } from './errors/clerkApiError'; export { ClerkAPIResponseError } from './errors/clerkApiResponseError'; +export { ClerkError } from './errors/clerkError'; export { buildErrorThrower, type ErrorThrower, type ErrorThrowerOptions } from './errors/errorThrower'; @@ -27,3 +28,5 @@ export { isUnauthorizedError, isUserLockedError, } from './errors/helpers'; + +export { createClerkGlobalHookError } from './errors/globalHookError'; diff --git a/packages/shared/src/errors/clerkApiError.ts b/packages/shared/src/errors/clerkApiError.ts index 2ec91934187..42fc43183ee 100644 --- a/packages/shared/src/errors/clerkApiError.ts +++ b/packages/shared/src/errors/clerkApiError.ts @@ -14,15 +14,7 @@ export class ClerkAPIError implements Cler 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 { + const parsedError = { code: json.code, message: json.message, longMessage: json.long_message, @@ -36,6 +28,10 @@ export class ClerkAPIError implements Cler isPlanUpgradePossible: json.meta?.is_plan_upgrade_possible, } as unknown as Meta, }; + this.code = parsedError.code; + this.message = parsedError.message; + this.longMessage = parsedError.longMessage; + this.meta = parsedError.meta; } } diff --git a/packages/shared/src/errors/globalHookError.ts b/packages/shared/src/errors/globalHookError.ts new file mode 100644 index 00000000000..c4615e16234 --- /dev/null +++ b/packages/shared/src/errors/globalHookError.ts @@ -0,0 +1,23 @@ +import { isClerkApiResponseError } from './clerkApiResponseError'; +import type { ClerkError } from './clerkError'; +import { isClerkRuntimeError } from './clerkRuntimeError'; + +/** + * Creates a ClerkGlobalHookError object from a ClerkError instance. + * It's a wrapper for all the different instances of Clerk errors that can + * be returned when using Clerk hooks. + */ +export function createClerkGlobalHookError(error: ClerkError) { + const predicates = { + isClerkApiResponseError, + isClerkRuntimeError, + } as const; + + for (const [name, fn] of Object.entries(predicates)) { + Object.assign(error, { [name]: fn }); + } + + return error as ClerkError & typeof predicates; +} + +export type ClerkGlobalHookError = ReturnType; diff --git a/packages/shared/src/types/state.ts b/packages/shared/src/types/state.ts index 4438d92fe57..6fe794cbd1e 100644 --- a/packages/shared/src/types/state.ts +++ b/packages/shared/src/types/state.ts @@ -1,3 +1,4 @@ +import type { ClerkGlobalHookError } from '../errors/globalHookError'; import type { SignInFutureResource } from './signInFuture'; import type { SignUpFutureResource } from './signUpFuture'; @@ -79,8 +80,9 @@ export interface Errors { raw: unknown[] | null; /** * Parsed errors that are not related to any specific field. + * Does not include any errors that could be parsed as a field error */ - global: unknown[] | null; // does not include any errors that could be parsed as a field error + global: ClerkGlobalHookError[] | null; } /** @@ -143,6 +145,7 @@ export interface State { * An alias for `effect()` from `alien-signals`, which can be used to subscribe to changes from Signals. * * @see https://github.com/stackblitz/alien-signals#usage + * * @experimental This experimental API is subject to change. */ __internal_effect: (callback: () => void) => () => void; @@ -152,6 +155,7 @@ export interface State { * its dependencies change. * * @see https://github.com/stackblitz/alien-signals#usage + * * @experimental This experimental API is subject to change. */ __internal_computed: (getter: (previousValue?: T) => T) => () => T;