Key Takeaways
- In a Shopify cart and checkout validation function, your config metafield lives at
input.validation.metafield, wherevalidationis a root sibling ofcart, not atinput.cart.validation.- Reading the wrong path returns
undefined, so the function loads an empty config and silently never enforces anything. Nothing errors; checkout just lets everything through.- The bug hid itself: the test fixture was written to match the buggy code (nesting
validationundercart), so the unit tests stayed green.- Turn on
// @ts-checkto catch the wrong path at author time, and shape fixtures to the real Shopify Input, not to your code.
A validation function that silently does nothing is one of the worst bugs to have, because there is no error, no log, no crash. Checkout simply permits an order it should have blocked. We shipped exactly that: a Shopify cart and checkout validation function that read its configuration from the wrong field of the Input object, loaded an empty config, and enforced nothing. The unit tests were green the entire time, because the fixture had been written to match the broken code.
This guide documents the exact path confusion, why it is so easy to make, why the test suite failed to catch it, and the two changes that make it impossible to reintroduce. The lesson generalizes well beyond Shopify: a test shaped to your code instead of to reality is worse than no test.
The Shape of the Validation Function Input
A Shopify cart and checkout validation function runs at the cart.validations.generate.run target and receives an Input object (Shopify, Cart and Checkout Validation Function API). The crucial detail is the shape of that object:
Input
├── buyerJourney (where the shopper is: cart vs checkout)
├── cart (the cart: lines, buyer identity, attributes)
└── validation (THIS validation instance, including its metafield)validation is a root-level sibling of cart. Your function's own configuration, stored as a metafield on the validation, is read from input.validation.metafield. The generated types make this explicit: export type Input = { cart, validation }.
The trap is that it reads naturally, and wrongly, as "the validation belongs to the cart," so you reach for input.cart.validation. That property does not exist. It returns undefined, and in JavaScript undefined?.metafield?.value quietly chains to undefined with no error.
The Bug: An Empty Config That Enforces Nothing
In this section you'll see the one-character-category mistake and why it produces total silence.
Here is the wrong read and its consequence:
// WRONG: `validation` is not under `cart`. This is always undefined.
const raw = input.cart.validation?.metafield?.value; // -> undefined
const config = raw ? JSON.parse(raw) : { rules: [] }; // -> { rules: [] }
// The function then loops an empty rules array and returns no errors.
// Checkout proceeds. Nothing is enforced. Nothing is logged.And the correct read:
// RIGHT: `validation` is a root sibling of `cart`.
const raw = input.validation?.metafield?.value; // -> the stored JSON config
const config = raw ? JSON.parse(raw) : { rules: [] }; // -> your real rulesWhat just happened: with the wrong path, config.rules is always empty, so the function has nothing to enforce and returns an empty operations list. The order limit, the variant cap, whatever the rule was, never fires. Because a validation function that returns no errors is a perfectly valid "allow everything" result, Shopify sees nothing wrong and checkout succeeds. This was the second root cause of an enforcement feature that never blocked anything (the first being that the function had not been deployed yet).
Why the Tests Stayed Green
In this section you'll see the self-hiding mechanism, which is the real lesson of this post.
The function had unit tests. They passed. They passed because the fixture was written to match the buggy code:
// tests/fixtures/over-limit.json — WRONG shape, matches the bug
{
"payload": {
"input": {
"cart": {
"lines": [ /* ... */ ],
"validation": { "metafield": { "value": "{\"rules\":[...]}" } }
}
}
}
}The test author, looking at the code that read input.cart.validation, built a fixture with validation nested under cart so the code would find it. Now the test exercised a shape that Shopify never actually sends. The code and the fixture agreed with each other and both disagreed with reality, so the suite was green while production enforced nothing.
The fixture must mirror the real Input, with validation at the root:
// tests/fixtures/over-limit.json — RIGHT shape, matches Shopify
{
"payload": {
"input": {
"cart": { "lines": [ /* ... */ ] },
"validation": { "metafield": { "value": "{\"rules\":[...]}" } }
}
}
}With the fixture corrected, the buggy input.cart.validation read returns undefined, the function enforces nothing, and the test that expects an error fails, exactly as it should.
The lesson: a fixture is a claim about what the outside world sends you. If you shape it to match your code, you convert your test suite into a mirror that only ever confirms what you already wrote. Shape fixtures to the real contract, then let them tell you when your code disagrees with it.
The Fix: Make the Wrong Path Un-writable
In this section you'll add the two guardrails that catch this class of bug before it ships.
Guardrail 1: // @ts-check. Add the directive to the top of the function file. Even in plain JavaScript, the editor and tsc then check against the generated types, and the wrong path becomes a compile-time error:
// @ts-check
/** @type {import('../generated/api').Input} */
// input.cart.validation -> Error: Property 'validation' does not exist on type 'Cart'.
const raw = input.validation?.metafield?.value;The generated GraphQL types know Cart has no validation property, so input.cart.validation is flagged immediately. This single directive would have caught the bug at author time.
Guardrail 2: type your self-defined config. The config JSON you store in the metafield is not part of the generated GraphQL types, so after JSON.parse it is implicitly any and no help. Give it a local @typedef so your rule-handling code is type-checked too:
/**
* @typedef {Object} RuleConfig
* @property {Array<{ id: string, max: number }>} rules
*/
/** @type {RuleConfig} */
const config = raw ? JSON.parse(raw) : { rules: [] };
// A message map indexed by string needs an explicit type to avoid implicit-any.
/** @type {Object<string, string>} */
const DEFAULT_MESSAGES = { over_limit: 'Quantity exceeds the allowed maximum.' };What just happened: @ts-check plus the generated Input type makes the wrong metafield path a hard error, and the local @typedef extends that safety to your own config shape. Together they turn a silent runtime no-op into a red squiggle in the editor.
Verification Without the Shopify CLI
You do not need the Shopify CLI to prove the function works. The run target is a pure function, so import it in Node and run it against a correctly shaped fixture:
// A fast, CLI-free check: pure function in, expected operations out.
import { run } from '../src/cart_validations_generate_run.js';
import fixture from './fixtures/over-limit.json' assert { type: 'json' };
const result = run(fixture.payload.input);
// Assert result matches fixture.payload.output (the expected operations).- Reproduce: point the function at
input.cart.validation, run it against a root-shaped fixture, and confirm it returns no errors (the silent bug). - Confirm the fix: switch to
input.validation.metafield, rerun, and confirm it now returns the expected validation error for an over-limit cart. - Confirm the type guard: with
// @ts-checkon, typeinput.cart.validationand confirm the editor flags "Property 'validation' does not exist on type 'Cart'."
Watch out: the repo's vitest helper for these functions expects the Shopify CLI on
PATH. In a sandbox without it, the pure-ESM import approach above runs the same logic with no CLI dependency.
Key Takeaways
1. validation is a root sibling of cart. Read your config from input.validation.metafield. input.cart.validation does not exist and silently returns undefined.
2. A validation function that reads an empty config enforces nothing. There is no error to notice, so this bug ships quietly and only surfaces as orders that should have been blocked.
3. Shape fixtures to reality, not to your code. A fixture built to match buggy code turns the test suite into a mirror. The fixture that mirrors Shopify's real Input is the one that fails loudly when the code is wrong.
4. // @ts-check is cheap insurance. With the generated types, it turns the wrong metafield path into a compile-time error. Add a @typedef for your own config so the safety covers your rule logic too.
Frequently Asked Questions
Where does a Shopify validation function read its configuration?
From input.validation.metafield.value, where validation is a root-level field of the Input object alongside cart and buyerJourney (Shopify, Cart and Checkout Validation Function API). Parse that string into your config object. Do not look under input.cart.
Why didn't the function throw an error with the wrong path?
Because optional chaining on a missing property returns undefined, not an error, and a validation function that returns no validation errors is a valid "allow everything" result. The function ran successfully and enforced nothing, which is why the bug was invisible until orders slipped through.
How did passing tests miss a non-working function?
The fixture was written with validation nested under cart to match the buggy read, so it tested a shape Shopify never sends. The code and the fixture agreed with each other while both disagreed with reality. Correcting the fixture to the real Input shape makes the test fail against the buggy code.
Can I unit test a Shopify function without the CLI?
Yes. The run target is a pure function, so you can import it as ESM in Node and call it with a fixture's input, asserting against the expected output. This avoids the CLI dependency that the default vitest helper carries, which matters in CI or sandboxes where the CLI is not installed.
Written by Duke, based on validation functions built for the Flicky Shopify app. Code samples are simplified to illustrate the pattern. See also the per-rule discount and validation architecture and the engineering notes hub.