Skip to content

feat: cart fragment override type support#3767

Open
andguy95 wants to merge 6 commits into
mainfrom
an-feat-generic-cart-types
Open

feat: cart fragment override type support#3767
andguy95 wants to merge 6 commits into
mainfrom
an-feat-generic-cart-types

Conversation

@andguy95
Copy link
Copy Markdown
Collaborator

WHY are these changes introduced?

Merchants who customize their cart GraphQL fragment (via cartQueryFragment / cartMutateFragment) couldn't get TypeScript to recognize the extra fields they added. They'd fetch metafields, custom attributes, or other additional data at runtime, but the return type was always the base Cart — so accessing those fields gave a TypeScript error even though the data was there. Attempts to address #2440

WHAT is this pull request doing?

Adds a TCart generic to createCartHandler and all cart query functions so the return type of every cart operation reflects the actual shape of the cart fragment being used.

Also introduces a new HydrogenCustomCartFragment augmentable global interface (in react-router.d.ts) that threads the type through to context.cart in route files without requiring any changes to how createHydrogenContext is called. When empty (the default), behaviour is identical to before. When extended with a codegen'd fragment type, all cart methods automatically return the extended type.

Example of what a dev might do

Here's that section rewritten with an inline copyable example:

1. Add the field to your cart fragment in app/lib/fragments.ts:

fragment CartApiQuery on Cart {
  # ...existing fields...
  foobar: metafields(identifiers: [{namespace: "custom", key: "gift_message"}]) {
    key
    value
  }
}

2. Run codegen to regenerate your types:

npm run codegen

3. Augment HydrogenCustomCartFragment in app/lib/context.ts:

import type {CartApiQueryFragment} from 'storefrontapi.generated';

declare global {
  interface HydrogenAdditionalContext extends AdditionalContextType {}

  // Add this — wires your codegen'd fragment type into context.cart
  interface HydrogenCustomCartFragment extends CartApiQueryFragment {}
}

All cart methods in every route file now reflect the extended type:

// In any route loader or action:
const cart = await context.cart.get();

// Before (without HydrogenCustomCartFragment augmentation):
cart?.foobar // ❌ Property 'metafields' does not exist on type 'Cart'

// After:
cart?.foobar // ✅ Array<Maybe<{ key: string; value: string }>> | null

HOW to test your changes?

  1. In the skeleton template
  2. In app/lib/fragments.ts, add a field to CART_QUERY_FRAGMENT that doesn't exist on the base Cart type (e.g. foobar: metafields)
  3. Run pnpm run codegen — the generated CartApiQueryFragment in storefrontapi.generated.d.ts will include the new field
  4. In app/lib/context.ts, add inside the existing declare global block:
    interface HydrogenCustomCartFragment extends CartApiQueryFragment {}
  5. In any route loader, access context.cart.get() and verify TypeScript now autocompletes and accepts the new field
  6. Remove the augmentation and verify TypeScript no longer knows about the field (confirms the default is unchanged)

To verify no regression for existing users: skip step 4 entirely and confirm context.cart still types correctly with the base Cart fields.

Checklist

  • I've read the Contributing Guidelines
  • I've considered possible cross-platform impacts (Mac, Linux, Windows)
  • I've added a changeset if this PR contains user-facing or functional changes. Test changes or internal-only config changes do not require a changeset.
  • I've added tests to cover my changes
  • I've added or updated the documentation

@shopify
Copy link
Copy Markdown
Contributor

shopify Bot commented May 19, 2026

Oxygen deployed a preview of your an-feat-generic-cart-types branch. Details:

Storefront Status Preview link Deployment details Last update (UTC)
Skeleton (skeleton.hydrogen.shop) ✅ Successful (Logs) Preview deployment Inspect deployment May 21, 2026 8:32 PM

Learn more about Hydrogen's GitHub integration.

Copy link
Copy Markdown

@theocerutti theocerutti left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im glad Hydrogen team did that! It looks good! Can't wait to be merged :)

Copy link
Copy Markdown
Contributor

@frandiox frandiox left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I left some comments but I think I misunderstood how the current Hydrogen works.

I was expecting Hydrogen to return the type of the internal cart fragment... but it seems we were returning the whole cart type, probably to account for custom cart fragments... 😬

In an ideal scenario, Hydrogen would return only the types for the internal cart fragment and let that interface be augmented in user-land by the generated type for the custom cart fragment.

However, reducing the current returned cart types to only the internal default fragment when no custom fragment is passed would be a breaking change in types I think, so perhaps the current PR is the best fit for now 🤔

Comment thread packages/hydrogen/src/cart/createCartHandler.ts
Comment thread packages/hydrogen/src/cart/createCartHandler.ts Outdated
Comment thread packages/hydrogen/src/index.ts Outdated
Comment thread packages/hydrogen/react-router.d.ts Outdated
@andguy95 andguy95 marked this pull request as ready for review May 20, 2026 18:25
@andguy95 andguy95 requested a review from a team as a code owner May 20, 2026 18:25
@andguy95 andguy95 requested a review from fredericoo May 20, 2026 18:26
@andguy95 andguy95 requested a review from frandiox May 20, 2026 18:52
@theocerutti
Copy link
Copy Markdown

theocerutti commented May 21, 2026

@andguy95 oh and maybe we should be able to pass a custom cart for Analytics.Provider ?
because currently the prop is typed with CartReturn
image

and same for all the subscribe / publish events payload:
image

could be another done in another PR however, it's less important

@andguy95
Copy link
Copy Markdown
Collaborator Author

@theocerutti That is a good call! I'll fast follow with another to add that support, so we can keep this PR scope to the cart handler :)

Copy link
Copy Markdown
Contributor

@fredericoo fredericoo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code works, but there are a few type/logic assertions i wish we could improve on

@@ -0,0 +1,5 @@
---
'@shopify/hydrogen': patch
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skeleton was changed as well!

Suggested change
'@shopify/hydrogen': patch
'@shopify/hydrogen': patch
'skeleton': patch
'@shopify/cli-hydrogen': patch
'@shopify/create-hydrogen': patch

options: CartHandlerOptionsWithRequiredCustom<TCustomMethods>,
): HydrogenCartCustom<TCustomMethods>;
export function createCartHandler<TCustomMethods extends CustomMethodsBase>(
export function createCartHandler<TCart = Cart, TGetExtraVariables = {}>(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: createCartHandler() defaults TCart to Cart, so the source-level HydrogenCustomCartFragment augmentation does not flow through the direct cart handler API.

HydrogenCart itself defaults to HydrogenCustomCartFragment & Cart, but this overload overrides that with TCart = Cart:

export function createCartHandler<TCart = Cart, TGetExtraVariables = {}>(
  options: CartHandlerOptions,
): HydrogenCart<TCart, TGetExtraVariables>;

That means a merchant can augment HydrogenCustomCartFragment, call createCartHandler({...}) directly, and still get HydrogenCart<Cart> unless they pass the generic manually. That contradicts the source-level augmentation described in cart-types.ts and the PR description.

Let's default the overload and implementation to HydrogenCustomCartFragment & Cart too, or remove the explicit Cart default so HydrogenCart's default is the single source of truth.

expectTypeOf<CartWithExplicitDefault>().toHaveProperty('addLines');
});

it('should allow augmentation to add custom fields to cart return types', () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: this test can pass even if the new global augmentation path is broken.

The test manually passes CustomFragment into HydrogenCart<CustomFragment>, so it only proves the explicit generic path works:

type CustomCart = HydrogenCart<CustomFragment>;

The feature this PR is adding is that merchants augment HydrogenCustomCartFragment once and then context.cart / createCartHandler pick it up without passing a generic everywhere. Let's add a type test that actually declares a global HydrogenCustomCartFragment augmentation and asserts the default cart type sees the custom field.

This probably wants to live in an isolated type fixture/file so the augmentation does not pollute the existing empty-default assertions in this test file.

This is blocking because the new test would still pass if the behaviour we are trying to protect regressed.


if (isCustomerLoggedIn && cart?.checkoutUrl) {
const finalCheckoutUrl = new URL(cart.checkoutUrl);
const cartWithCheckoutUrl = cart as
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: let's not add as assertions in the new generic cart path.

cart is typed as TCart | null, then asserted into something with checkoutUrl so we can mutate the logged-in checkout URL:

const cartWithCheckoutUrl = cart as
  | (TCart & {checkoutUrl?: string})
  | null
  | undefined;

That bypasses the type system exactly where this PR is trying to make cart typing safer. Let's constrain the generic return type to the cart fields the handler actually needs, or use a small type guard before reading/mutating checkoutUrl.


const result = await _cartCreate(...args);
cartId = result?.cart?.id;
cartId = (result?.cart as {id?: string} | undefined)?.id;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: same issue here - TCart is unconstrained, but the handler still requires cart.id to persist the cart ID after cartCreate.

can you try this:

if (!result?.cart || 'id' in result.cart === false || typeof result.cart.id !== 'string') throw new Error("Cart needs an id");
cartId = result.cart.id;

If the generic cart type does not include id, TypeScript should force us to model that contract instead of letting the handler silently lose the cart ID.

Ideally also constrain TCart to include the required base cart fields, but throwing if missing a required base part is acceptable

optionalParams,
)
: await cartCreate({metafields}, optionalParams);
: ((await cartCreate({metafields}, optionalParams)) as Awaited<
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: this cast hides a return-type mismatch in the setMetafields fallback path.

When there is no cart ID, setMetafields creates a cart and then casts the cartCreate() result to CartMetafieldsSetFunction's return type:

: ((await cartCreate({metafields}, optionalParams)) as Awaited<
    ReturnType<CartMetafieldsSetFunction>
  >);

That makes the branch compile, but it also means this method is not honestly typed through the new TCart path. Let's model this return type directly instead of asserting it away, even if the answer is that setMetafields intentionally stays on the existing non-generic metafield return shape.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants