diff --git a/.changeset/brown-wings-shake.md b/.changeset/brown-wings-shake.md new file mode 100644 index 00000000000..62f29b5ad17 --- /dev/null +++ b/.changeset/brown-wings-shake.md @@ -0,0 +1,8 @@ +--- +'@clerk/shared': minor +'@clerk/nextjs': minor +'@clerk/clerk-react': minor +'@clerk/clerk-js': patch +--- + +[Billing Beta] Stricter return type of `useCheckout` to improve inference of other properties. diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index 127548908e1..9fb0cbb07ef 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -279,9 +279,9 @@ export const PayWithTestPaymentSource = () => { const AddPaymentSourceForCheckout = withCardStateProvider(() => { const { addPaymentSourceAndPay } = useCheckoutMutations(); const { checkout } = useCheckout(); - const { id, totals } = checkout; + const { status, totals } = checkout; - if (!id) { + if (status === 'needs_initialization') { return null; } diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx index d8f4c901b88..1ee0998a5a2 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx @@ -62,7 +62,7 @@ const FetchStatus = ({ const { fetchStatus, error } = checkout; const internalFetchStatus = useMemo(() => { - if (fetchStatus === 'error' && error?.errors) { + if (fetchStatus === 'error') { const errorCodes = error.errors.map(e => e.code); if (errorCodes.includes('missing_payer_email')) { @@ -75,7 +75,7 @@ const FetchStatus = ({ } return fetchStatus; - }, [fetchStatus, error]); + }, [fetchStatus]); if (internalFetchStatus !== status) { return null; diff --git a/packages/shared/src/react/hooks/__tests__/useCheckout.type.spec.ts b/packages/shared/src/react/hooks/__tests__/useCheckout.type.spec.ts index 4ea2f874968..d238dd3dbd4 100644 --- a/packages/shared/src/react/hooks/__tests__/useCheckout.type.spec.ts +++ b/packages/shared/src/react/hooks/__tests__/useCheckout.type.spec.ts @@ -1,5 +1,6 @@ import type { __experimental_CheckoutCacheState, + __experimental_CheckoutInstance, ClerkAPIResponseError, CommerceCheckoutResource, CommerceSubscriptionPlanPeriod, @@ -158,5 +159,96 @@ describe('useCheckout type tests', () => { >(); }); }); + + describe('discriminated unions', () => { + describe('error state discrimination', () => { + it('has correct fetchStatus type union', () => { + type FetchStatus = CheckoutObject['fetchStatus']; + expectTypeOf().toEqualTypeOf<'idle' | 'fetching' | 'error'>(); + }); + + it('has correct error type union', () => { + type ErrorType = CheckoutObject['error']; + expectTypeOf().toMatchTypeOf(); + }); + + it('enforces error state correlation', () => { + // When fetchStatus is 'error', error should not be null + type ErrorFetchState = CheckoutObject & { fetchStatus: 'error' }; + expectTypeOf().not.toEqualTypeOf(); + + // When fetchStatus is not 'error', error must be null + type NonErrorFetchState = CheckoutObject & { fetchStatus: 'idle' | 'fetching' }; + expectTypeOf().toEqualTypeOf(); + }); + }); + + describe('status-based property discrimination', () => { + it('has correct status type union', () => { + type Status = CheckoutObject['status']; + expectTypeOf().toEqualTypeOf<'needs_initialization' | 'needs_confirmation' | 'completed'>(); + }); + + it('enforces null properties when status is needs_initialization', () => { + type InitializationState = CheckoutObject & { status: 'needs_initialization' }; + + // Test that properties are nullable (null or undefined) in initialization state + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + + // Test that the status property is correctly typed + expectTypeOf().toEqualTypeOf<'needs_initialization'>(); + }); + + it('enforces proper types when status is needs_confirmation or completed', () => { + type ConfirmationState = CheckoutObject & { status: 'needs_confirmation' }; + type CompletedState = CheckoutObject & { status: 'completed' }; + + // These should not be null for confirmation and completed states + expectTypeOf().not.toEqualTypeOf(); + expectTypeOf().not.toEqualTypeOf(); + expectTypeOf().not.toEqualTypeOf(); + + expectTypeOf().not.toEqualTypeOf(); + expectTypeOf().not.toEqualTypeOf(); + expectTypeOf().not.toEqualTypeOf(); + }); + }); + + describe('type structure validation', () => { + it('validates the overall discriminated union structure', () => { + // Test that CheckoutObject is a proper discriminated union + type CheckoutUnion = CheckoutObject; + + // Should include all required properties + expectTypeOf().toHaveProperty('status'); + expectTypeOf().toHaveProperty('fetchStatus'); + expectTypeOf().toHaveProperty('error'); + expectTypeOf().toHaveProperty('id'); + expectTypeOf().toHaveProperty('confirm'); + expectTypeOf().toHaveProperty('start'); + expectTypeOf().toHaveProperty('clear'); + expectTypeOf().toHaveProperty('finalize'); + expectTypeOf().toHaveProperty('getState'); + expectTypeOf().toHaveProperty('isStarting'); + expectTypeOf().toHaveProperty('isConfirming'); + }); + + it('validates method types remain unchanged', () => { + // Ensure the discriminated union doesn't affect method types + expectTypeOf().toEqualTypeOf<__experimental_CheckoutInstance['confirm']>(); + expectTypeOf().toEqualTypeOf<__experimental_CheckoutInstance['start']>(); + expectTypeOf().toEqualTypeOf<() => void>(); + expectTypeOf().toEqualTypeOf<(params?: { redirectUrl: string }) => void>(); + expectTypeOf().toEqualTypeOf<() => __experimental_CheckoutCacheState>(); + }); + }); + }); }); }); diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index 64bd15b1f38..a7e89dc3c56 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -28,21 +28,40 @@ type ForceNull = { type CheckoutProperties = Omit, 'pathRoot' | 'status'>; -type NullableCheckoutProperties = CheckoutProperties | ForceNull; +type FetchStatusAndError = + | { + error: ClerkAPIResponseError; + fetchStatus: 'error'; + } + | { + error: null; + fetchStatus: 'idle' | 'fetching'; + }; + +/** + * @internal + * On status === 'needs_initialization', all properties are null. + * On status === 'needs_confirmation' or 'completed', all properties are defined the same as the CommerceCheckoutResource. + */ +type CheckoutPropertiesPerStatus = + | ({ + status: Extract<__experimental_CheckoutCacheState['status'], 'needs_initialization'>; + } & ForceNull) + | ({ + status: Extract<__experimental_CheckoutCacheState['status'], 'needs_confirmation' | 'completed'>; + } & CheckoutProperties); type __experimental_UseCheckoutReturn = { - checkout: NullableCheckoutProperties & { - confirm: __experimental_CheckoutInstance['confirm']; - start: __experimental_CheckoutInstance['start']; - isStarting: boolean; - isConfirming: boolean; - error: ClerkAPIResponseError | null; - status: __experimental_CheckoutCacheState['status']; - clear: () => void; - finalize: (params?: { redirectUrl: string }) => void; - fetchStatus: 'idle' | 'fetching' | 'error'; - getState: () => __experimental_CheckoutCacheState; - }; + checkout: FetchStatusAndError & + CheckoutPropertiesPerStatus & { + confirm: __experimental_CheckoutInstance['confirm']; + start: __experimental_CheckoutInstance['start']; + clear: () => void; + finalize: (params?: { redirectUrl: string }) => void; + getState: () => __experimental_CheckoutCacheState; + isStarting: boolean; + isConfirming: boolean; + }; }; type Params = Parameters[0]; @@ -74,7 +93,7 @@ export const useCheckout = (options?: Params): __experimental_UseCheckoutReturn () => manager.getState(), ); - const properties = useMemo(() => { + const properties = useMemo>(() => { if (!managerProperties.checkout) { return { id: null, @@ -115,5 +134,5 @@ export const useCheckout = (options?: Params): __experimental_UseCheckoutReturn return { checkout, - }; + } as __experimental_UseCheckoutReturn; };