Skip to main content
The panels are fully typed against the API through @mercurjs/types and the typed SDK. When you extend the backend — adding custom fields, a linked module, an extra field on a DTO — those additions aren’t in the shipped types yet. You close the gap in the frontend with a small declaration-merging .d.ts file. Do that once and every SDK call that returns the entity carries your field, typed.
Never use any to paper over a missing field. Casting a response to any (or as { custom_fields: … } at each call site) throws away type-checking and has to be repeated everywhere. Augment the type once instead.

The scenario: a custom field, typed end-to-end

Say you added a custom field on the backend — for example is_featured on product (see Custom fields). The value now comes back from the API, but the panel’s ProductDTO doesn’t know about it, so product.is_featured is a type error. Fix it in the panel with a declaration-merging file.

Why merging works here

The ProductDTO the SDK returns ultimately resolves to Medusa’s upstream ProductDTO, which is declared as an interface in @medusajs/types. Interfaces are open, so you can merge into it with declare module "@medusajs/types". Because everything downstream — @mercurjs/types, the SDK response wrappers (AdminProductResponse, list responses), and the panel hooks — refers back to that same interface, your added members appear in all of them at once. You augment in one place and every product-returning endpoint is typed.

Add the .d.ts in the panel

Drop a declaration file anywhere under the panel’s src/ (it’s picked up by the app’s tsconfig):
apps/vendor/src/types/custom-fields.d.ts
import "@medusajs/types"

declare module "@medusajs/types" {
  interface ProductDTO {
    custom_fields?: {
      is_featured?: boolean
    }
  }
}
Two rules or the augmentation silently does nothing:
  • The module name in declare module "..." must be the package that declares the interface you’re merging into — here @medusajs/types, the owner of UpstreamProductDTO, not @mercurjs/types (which only aliases it).
  • The file must be a module. Add an import "@medusajs/types" (or a trailing export {}) so TypeScript treats it as one.

Now the whole SDK is typed

With that one file in place, no cast is needed anywhere:
Every product endpoint carries the field
const { products } = await sdk.vendor.products.query()
products[0].custom_fields?.is_featured // ✅ typed, no cast

const { product } = await sdk.vendor.products.$id.query({ $id: id })
product.custom_fields?.is_featured     // ✅ typed everywhere ProductDTO flows
Types and runtime are separate concerns: this .d.ts makes the field typed, but it only arrives if the fetch asks for it. Let the custom-fields link / registry merge add the fields to the built-in panel fetches rather than hand-adding +field.* — the vendor product query in particular rejects arbitrary *-relation overrides.

Linked data resolves the same way

The augmentation isn’t limited to a custom field’s own value — it’s how you make linked-module data typed too. When a custom-fields config declares a link, the panel fetches that module’s data alongside the entity (SPEC-021): link: "brand" merges brand.* into the built-in product fetch for you — no hand-written field list. Pair that one config line with a matching augmentation, and the linked data is both present at runtime and typed everywhere ProductDTO is imported:
src/custom-fields/product.tsx — declare the link (runtime)
export default defineCustomFieldsConfig({
  model: "product",
  link: "brand", // brand.* is fetched with every product
  list: {
    columns: [
      { id: "brand_name", header: "Brand", component: ({ row }) => row.brand?.name },
    ],
  },
})
src/types/brand.d.ts — declare the shape (types)
import "@medusajs/types"

declare module "@medusajs/types" {
  interface ProductDTO {
    brand?: { id: string; name: string }
  }
}
Now any code that imports ProductDTO — a page, a hook, a column renderer — sees product.brand resolved, with the data already fetched by the link:
const { product } = await sdk.vendor.products.$id.query({ $id: id })
product.brand?.name // ✅ present (via link) and typed (via augmentation)
The link does the fetching, the .d.ts does the typing — you write each once, per model, and every product-returning endpoint in the panel is covered. This is the payoff of augmentation: register the relationship in one place, consume it as a plain typed property everywhere.

Where each type goes

You’re adding…Put it in
A field your API now returns on an entityA panel .d.ts merging into the framework DTO (declare module "@medusajs/types")
A one-off request/response shape for a custom routeInfer it from the Zod validator (z.infer<typeof Schema>) and export it
A brand-new Mercur/domain DTOThe domain folder in @mercurjs/types, re-exported from index.ts
DTOs and enums the platform already ships (ProductDTO, SellerStatus, MercurModules, HttpTypes) are imported from @mercurjs/types — never redeclared. In the dashboards, HttpTypes comes from @mercurjs/types too, which is what keeps request/response types aligned with Mercur’s extended routes.

Checklist

  • No any, and no per-call-site casts for extended data.
  • Backend additions typed in the panel via a .d.ts merging into the framework interface that owns the DTO.
  • Augmentation files name the correct package and are real modules (import/export {}).
  • Extended fields are requested with +field.* so they actually arrive.
  • Shipped types imported from @mercurjs/types; one-off shapes inferred from Zod.