Skip to main content

API Schemas

Using Zod Schemas for Response Validation

In our project, we use Zod for strict validation of server responses. This prevents unexpected crashes on the frontend and makes the API contract self‑documenting.

Example of invoking the API by passing a schema to the createRequest wrapper:

import * as z from "zod"
import { createRequest } from 'shop/client/api';

export function actionGetCart(cartId: string) {
return createRequest({
url: `users/current/carts/${cartId}`,
schema: CartSchema,
});
}
  • url — the endpoint path.
  • schema — the Zod schema that will validate and parse the response into the expected type, or throw an error if something is wrong.
  • The return value of createRequest will already be strictly typed as Cart (see below).

Creating and Extending Schemas

To avoid duplication and maintain a core structure, we extract "default" parts into separate schemas and extend them as needed:

import * as z from "zod"
import {
DefaultCartEntrySchema,
CartUserSchema,
CartEntrySchema,
DeliveryModeSchema,
DefaultPaymentInfoSchema,
CartDeliveryCostSchema,
DeliveryAddressSchema,
CartVoucherSchema,
PriceSchema,
} from '@archibald/storefront';

// The base cart schema that is returned in most cases
export const DefaultCartSchema = z.object({
guid: z.string(),
code: z.string(),
entries: z.array(DefaultCartEntrySchema),
totalItems: z.number().optional(),
totalPrice: PriceSchema,
subTotal: PriceSchema.optional(),
totalPriceWithTax: PriceSchema.optional(),
});
export type DefaultCart = z.infer<typeof DefaultCartSchema>;

// Extend the default schema: add new fields or override existing ones
export const CartSchema = DefaultCartSchema.extend({
name: z.string().optional(),
user: CartUserSchema,
entries: z.array(CartEntrySchema).optional(),
deliveryMode: DeliveryModeSchema.optional(),
paymentInfo: DefaultPaymentInfoSchema.optional(),
deliveryCost: CartDeliveryCostSchema.optional(),
deliveryAddress: DeliveryAddressSchema.optional(),
appliedVouchers: z.array(CartVoucherSchema).optional(),
});
export type Cart = z.infer<typeof CartSchema>;

How it works:

  1. DefaultCartSchema describes the "core" of the cart object.

  2. DefaultCartSchema.extend({...}) returns a new "inherited" schema where you can:

    • Add new fields.
    • Change existing types.
  3. z.infer<typeof Schema> automatically derives the TypeScript type from the schema.

  4. Zod Utility Methods examples to transform schemas:

    • .partial() — makes all properties optional:

      const PartialCartSchema = CartSchema.partial();
      export type PartialCart = z.infer<typeof PartialCartSchema>;
    • .required() — makes all optional properties required:

      const FullCartSchema = CartSchema.required();
      export type FullCart = z.infer<typeof FullCartSchema>;
    • .pick() — creates a schema with a subset of properties:

      const CartIdSchema = CartSchema.pick({ guid: true, code: true });
      export type CartId = z.infer<typeof CartIdSchema>;

Special Cases:

Augmented Schemas with Interface Merging

Problem

We have a base Product interface in the @archibald/storefront package:

export interface Product {
code: string;
name: string;
url: string;
description: string;
inStock: boolean;
images: ProductImage[];
price: Price;
discountPrice?: Price;
lowestPrice?: Price;
badges?: string[];
}

Another package (e.g., @archibald/commerce) augments this interface via module merging:

declare module '@archibald/storefront' {
interface Product {
custom: Omit<CommerceProduct, keyof Product>;
}
}

Why the “usual” schema + infer interface pattern doesn’t work:

Zod runs only at runtime and has no knowledge of compile-time module augmentations. If you define a schema first and then rely on TypeScript’s interface merging to inject extra fields, Zod will silently strip out or reject those fields because they aren’t in its runtime shape. In other words, Zod can’t “pick up” new types added later via declare module, so you lose the augmented keys at validation time.

Key insight:

We must invert the flow -> start from the interface and build a schema that allows extra keys, rather than defining a closed schema and hoping Zod will magically pick up later augmentations.

If you build the Zod schema using the satisfies z.ZodType<...> pattern:

export const ProductSchema = z.object({
code: z.string(),
name: z.string(),
// … other fields …
}) satisfies z.ZodType<Product>;

it does not include the later-injected custom field as well, so:

  • Any extension or usage of ProductSchema (e.g. in CartSchema) will treat custom as unknown and optional.
  • Components expecting product.custom will fail at runtime or get type errors, despite the TS interface requiring it.

Solution:

We introduce a small factory that:

  1. Builds a Zod schema from the provided raw shape.
  2. Uses .looseObject() so extra (augmented) keys survive validation.
  3. Casts the result back to a full ZodObject matching the final interface (provided in <T>).
export function makeAugmentationSchema<Entity>() {
return function <Shape extends z.ZodRawShape>(shape: Shape) {
return z.looseObject(shape) as z.ZodType<Entity>;
};
}
Usage
import * as z from "zod"
import { makeAugmentationSchema } from '@archibald/core';
import { Product } from '@archibald/storefront';

export interface Product {
code: string;
name: string;
url: string;
// ...
}

export const ProductSchema = makeAugmentationSchema<Product>()({
code: z.string(),
name: z.string(),
url: z.string(),
description: z.string(),
inStock: z.boolean(),
images: z.array(ProductImageSchema),
price: PriceSchema,
discountPrice: PriceSchema.optional(),
lowestPrice: PriceSchema.optional(),
badges: z.array(z.string()).optional(),
});
  • Extra keys (such as custom) are preserved by .looseObject() and retain the types defined via interface merging.
  • All Zod helpers (.extend(), .partial(), .pick(), etc.) remain available.
  • Subsequent augmentations in other packages will not break the core schema.