Skip to content

Commit 9368daf

Browse files
authored
fix(clerk-js, shared, nextjs): Improve typesafety of useCheckout (#6473)
1 parent 7b6dcee commit 9368daf

File tree

5 files changed

+138
-19
lines changed

5 files changed

+138
-19
lines changed

.changeset/brown-wings-shake.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@clerk/shared': minor
3+
'@clerk/nextjs': minor
4+
'@clerk/clerk-react': minor
5+
'@clerk/clerk-js': patch
6+
---
7+
8+
[Billing Beta] Stricter return type of `useCheckout` to improve inference of other properties.

packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,9 +279,9 @@ export const PayWithTestPaymentSource = () => {
279279
const AddPaymentSourceForCheckout = withCardStateProvider(() => {
280280
const { addPaymentSourceAndPay } = useCheckoutMutations();
281281
const { checkout } = useCheckout();
282-
const { id, totals } = checkout;
282+
const { status, totals } = checkout;
283283

284-
if (!id) {
284+
if (status === 'needs_initialization') {
285285
return null;
286286
}
287287

packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ const FetchStatus = ({
6262
const { fetchStatus, error } = checkout;
6363

6464
const internalFetchStatus = useMemo(() => {
65-
if (fetchStatus === 'error' && error?.errors) {
65+
if (fetchStatus === 'error') {
6666
const errorCodes = error.errors.map(e => e.code);
6767

6868
if (errorCodes.includes('missing_payer_email')) {
@@ -75,7 +75,7 @@ const FetchStatus = ({
7575
}
7676

7777
return fetchStatus;
78-
}, [fetchStatus, error]);
78+
}, [fetchStatus]);
7979

8080
if (internalFetchStatus !== status) {
8181
return null;

packages/shared/src/react/hooks/__tests__/useCheckout.type.spec.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
__experimental_CheckoutCacheState,
3+
__experimental_CheckoutInstance,
34
ClerkAPIResponseError,
45
CommerceCheckoutResource,
56
CommerceSubscriptionPlanPeriod,
@@ -158,5 +159,96 @@ describe('useCheckout type tests', () => {
158159
>();
159160
});
160161
});
162+
163+
describe('discriminated unions', () => {
164+
describe('error state discrimination', () => {
165+
it('has correct fetchStatus type union', () => {
166+
type FetchStatus = CheckoutObject['fetchStatus'];
167+
expectTypeOf<FetchStatus>().toEqualTypeOf<'idle' | 'fetching' | 'error'>();
168+
});
169+
170+
it('has correct error type union', () => {
171+
type ErrorType = CheckoutObject['error'];
172+
expectTypeOf<ErrorType>().toMatchTypeOf<ClerkAPIResponseError | null>();
173+
});
174+
175+
it('enforces error state correlation', () => {
176+
// When fetchStatus is 'error', error should not be null
177+
type ErrorFetchState = CheckoutObject & { fetchStatus: 'error' };
178+
expectTypeOf<ErrorFetchState['error']>().not.toEqualTypeOf<null>();
179+
180+
// When fetchStatus is not 'error', error must be null
181+
type NonErrorFetchState = CheckoutObject & { fetchStatus: 'idle' | 'fetching' };
182+
expectTypeOf<NonErrorFetchState['error']>().toEqualTypeOf<null>();
183+
});
184+
});
185+
186+
describe('status-based property discrimination', () => {
187+
it('has correct status type union', () => {
188+
type Status = CheckoutObject['status'];
189+
expectTypeOf<Status>().toEqualTypeOf<'needs_initialization' | 'needs_confirmation' | 'completed'>();
190+
});
191+
192+
it('enforces null properties when status is needs_initialization', () => {
193+
type InitializationState = CheckoutObject & { status: 'needs_initialization' };
194+
195+
// Test that properties are nullable (null or undefined) in initialization state
196+
expectTypeOf<InitializationState['id']>().toEqualTypeOf<null>();
197+
expectTypeOf<InitializationState['externalClientSecret']>().toEqualTypeOf<null>();
198+
expectTypeOf<InitializationState['externalGatewayId']>().toEqualTypeOf<null>();
199+
expectTypeOf<InitializationState['totals']>().toEqualTypeOf<null>();
200+
expectTypeOf<InitializationState['isImmediatePlanChange']>().toEqualTypeOf<null>();
201+
expectTypeOf<InitializationState['planPeriod']>().toEqualTypeOf<null>();
202+
expectTypeOf<InitializationState['plan']>().toEqualTypeOf<null>();
203+
expectTypeOf<InitializationState['paymentSource']>().toEqualTypeOf<null | undefined>();
204+
205+
// Test that the status property is correctly typed
206+
expectTypeOf<InitializationState['status']>().toEqualTypeOf<'needs_initialization'>();
207+
});
208+
209+
it('enforces proper types when status is needs_confirmation or completed', () => {
210+
type ConfirmationState = CheckoutObject & { status: 'needs_confirmation' };
211+
type CompletedState = CheckoutObject & { status: 'completed' };
212+
213+
// These should not be null for confirmation and completed states
214+
expectTypeOf<ConfirmationState['id']>().not.toEqualTypeOf<null>();
215+
expectTypeOf<ConfirmationState['totals']>().not.toEqualTypeOf<null>();
216+
expectTypeOf<ConfirmationState['plan']>().not.toEqualTypeOf<null>();
217+
218+
expectTypeOf<CompletedState['id']>().not.toEqualTypeOf<null>();
219+
expectTypeOf<CompletedState['totals']>().not.toEqualTypeOf<null>();
220+
expectTypeOf<CompletedState['plan']>().not.toEqualTypeOf<null>();
221+
});
222+
});
223+
224+
describe('type structure validation', () => {
225+
it('validates the overall discriminated union structure', () => {
226+
// Test that CheckoutObject is a proper discriminated union
227+
type CheckoutUnion = CheckoutObject;
228+
229+
// Should include all required properties
230+
expectTypeOf<CheckoutUnion>().toHaveProperty('status');
231+
expectTypeOf<CheckoutUnion>().toHaveProperty('fetchStatus');
232+
expectTypeOf<CheckoutUnion>().toHaveProperty('error');
233+
expectTypeOf<CheckoutUnion>().toHaveProperty('id');
234+
expectTypeOf<CheckoutUnion>().toHaveProperty('confirm');
235+
expectTypeOf<CheckoutUnion>().toHaveProperty('start');
236+
expectTypeOf<CheckoutUnion>().toHaveProperty('clear');
237+
expectTypeOf<CheckoutUnion>().toHaveProperty('finalize');
238+
expectTypeOf<CheckoutUnion>().toHaveProperty('getState');
239+
expectTypeOf<CheckoutUnion>().toHaveProperty('isStarting');
240+
expectTypeOf<CheckoutUnion>().toHaveProperty('isConfirming');
241+
});
242+
243+
it('validates method types remain unchanged', () => {
244+
// Ensure the discriminated union doesn't affect method types
245+
expectTypeOf<CheckoutObject['confirm']>().toEqualTypeOf<__experimental_CheckoutInstance['confirm']>();
246+
expectTypeOf<CheckoutObject['start']>().toEqualTypeOf<__experimental_CheckoutInstance['start']>();
247+
expectTypeOf<CheckoutObject['clear']>().toEqualTypeOf<() => void>();
248+
expectTypeOf<CheckoutObject['finalize']>().toEqualTypeOf<(params?: { redirectUrl: string }) => void>();
249+
expectTypeOf<CheckoutObject['getState']>().toEqualTypeOf<() => __experimental_CheckoutCacheState>();
250+
});
251+
});
252+
});
161253
});
162254
});

packages/shared/src/react/hooks/useCheckout.ts

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,40 @@ type ForceNull<T> = {
2828

2929
type CheckoutProperties = Omit<RemoveFunctions<CommerceCheckoutResource>, 'pathRoot' | 'status'>;
3030

31-
type NullableCheckoutProperties = CheckoutProperties | ForceNull<CheckoutProperties>;
31+
type FetchStatusAndError =
32+
| {
33+
error: ClerkAPIResponseError;
34+
fetchStatus: 'error';
35+
}
36+
| {
37+
error: null;
38+
fetchStatus: 'idle' | 'fetching';
39+
};
40+
41+
/**
42+
* @internal
43+
* On status === 'needs_initialization', all properties are null.
44+
* On status === 'needs_confirmation' or 'completed', all properties are defined the same as the CommerceCheckoutResource.
45+
*/
46+
type CheckoutPropertiesPerStatus =
47+
| ({
48+
status: Extract<__experimental_CheckoutCacheState['status'], 'needs_initialization'>;
49+
} & ForceNull<CheckoutProperties>)
50+
| ({
51+
status: Extract<__experimental_CheckoutCacheState['status'], 'needs_confirmation' | 'completed'>;
52+
} & CheckoutProperties);
3253

3354
type __experimental_UseCheckoutReturn = {
34-
checkout: NullableCheckoutProperties & {
35-
confirm: __experimental_CheckoutInstance['confirm'];
36-
start: __experimental_CheckoutInstance['start'];
37-
isStarting: boolean;
38-
isConfirming: boolean;
39-
error: ClerkAPIResponseError | null;
40-
status: __experimental_CheckoutCacheState['status'];
41-
clear: () => void;
42-
finalize: (params?: { redirectUrl: string }) => void;
43-
fetchStatus: 'idle' | 'fetching' | 'error';
44-
getState: () => __experimental_CheckoutCacheState;
45-
};
55+
checkout: FetchStatusAndError &
56+
CheckoutPropertiesPerStatus & {
57+
confirm: __experimental_CheckoutInstance['confirm'];
58+
start: __experimental_CheckoutInstance['start'];
59+
clear: () => void;
60+
finalize: (params?: { redirectUrl: string }) => void;
61+
getState: () => __experimental_CheckoutCacheState;
62+
isStarting: boolean;
63+
isConfirming: boolean;
64+
};
4665
};
4766

4867
type Params = Parameters<typeof __experimental_CheckoutProvider>[0];
@@ -78,7 +97,7 @@ export const useCheckout = (options?: Params): __experimental_UseCheckoutReturn
7897
() => manager.getState(),
7998
);
8099

81-
const properties = useMemo<NullableCheckoutProperties>(() => {
100+
const properties = useMemo<CheckoutProperties | ForceNull<CheckoutProperties>>(() => {
82101
if (!managerProperties.checkout) {
83102
return {
84103
id: null,
@@ -119,5 +138,5 @@ export const useCheckout = (options?: Params): __experimental_UseCheckoutReturn
119138

120139
return {
121140
checkout,
122-
};
141+
} as __experimental_UseCheckoutReturn;
123142
};

0 commit comments

Comments
 (0)