@by-association-only/metaobjects
v0.2.2
Published
Readme
@by-association-only/metaobjects
Define a Shopify metaobject type once with a field schema and get back a typed
class with CRUD, querying, and automatic serialisation to and from Shopify's
{ key, value }[] field format. Every app that touches metaobjects otherwise
hand-rolls the same serialisation and boilerplate: turning numbers and booleans
into strings, JSON-encoding structured values, mapping snake_case Shopify keys
back to camelCase properties, and wiring up metaobjectCreate /
metaobjectUpdate / metaobjectDelete. This package does it from the schema, so
you write the schema and get the rest.
Install
bun add @by-association-only/metaobjectsEverything runtime takes an AdminApiClient and makes its own GraphQL calls, so
you also need the client that provides it:
bun add @by-association-only/shopify-graphql-clientThe package is transport-agnostic. It has no coupling to TanStack Router, Query, or any framework: pass a client, get results back.
Quick start
import {
defineMetaobject,
MetafieldType,
} from "@by-association-only/metaobjects";
const Store = defineMetaobject("$app:store", {
title: MetafieldType.SingleLineTextField,
address1: { key: "address_1", type: MetafieldType.SingleLineTextField },
sortOrder: { key: "sort_order", type: MetafieldType.NumberInteger },
notes: { type: MetafieldType.MultiLineTextField, optional: true },
});That call returns a class. Use its statics for the ID-plus-values operations
(list, load, create, form-style update, bulk delete) and the instance update
when you already have a loaded record:
// List. Defaults to the first 50, forward paginated.
const page = await Store.all(client);
page.nodes; // Store[]
page.pageInfo; // { hasNextPage, hasPreviousPage, startCursor, endCursor }
// Load one by GID or numeric id (a number is composed into a GID for you).
const store = await Store.find(client, "gid://shopify/Metaobject/123");
if (store) {
store.fields.title; // string
store.fields.sortOrder; // number
store.id; // ShopifyGid<"Metaobject">
store.handle;
store.createdAt; // Date
}
// Create. Returns a DomainResult, not the record directly.
const created = await Store.create(client, {
title: "London",
address1: "1 High Street",
sortOrder: 3,
});
if (created.ok) {
created.data.fields.title; // "London"
}
// Update, form-style: id + values (e.g. a form submission).
await Store.update(client, store.id, { title: "New name" });
// Update, instance-style: you already loaded the record.
await store.update(client, { title: "New name" });
// Delete one or many.
await Store.deleteMany(client, [store.id]);fields access is typed off the schema, so store.fields.sortOrder is a
number and store.fields.notes is string | null (optional).
Method reference
| Method | Signature | Returns |
| --- | --- | --- |
| Store.all | (client, params?) | MetaobjectPage<Store> |
| Store.find | (client, id: string \| number) | Store \| null |
| Store.create | (client, fields, handle?, status?) | DomainResult<Store> |
| Store.update | (client, id, values, status?) | DomainResult<Store> |
| store.update | (client, values) | DomainResult<Store> |
| Store.deleteMany | (client, ids: string[]) | DeleteManyResult |
| Store.hydrate | (node) | Store |
| Store.type | static property | string |
| Store.schema | static property | the schema you passed |
deleteMany is the odd one out: it does not return a DomainResult, it returns
a per-id breakdown so a partial failure tells you which ids failed. See
Results and errors.
all accepts standard connection params, validated by
metaobjectQueryParamsSchema (a Zod schema exported for reuse):
await Store.all(client, {
first: 20,
query: "title:London",
sortKey: "updated_at",
reverse: true,
});
// Backward pagination: passing `before` or `last` flips to a backward page,
// defaulting `last` to 50.
await Store.all(client, { before: cursor });Field schemas
A schema maps a property name to a field definition. Two forms:
{
// String shorthand: use when the property name IS the Shopify field key.
title: MetafieldType.SingleLineTextField,
// Object form: use when they differ, i.e. camelCase property to snake_case key.
sortOrder: { key: "sort_order", type: MetafieldType.NumberInteger },
// Optional. Adds `?` and widens the value to `T | null`.
notes: { type: MetafieldType.MultiLineTextField, optional: true },
// List. The value type becomes an array.
items: { type: MetafieldType.List.MetaobjectReference, optional: true },
}optional must be the literal true. It is what splits required keys
(T) from optional keys (T | null with ?) in the inferred value type.
MetafieldType covers the Shopify metafield types and maps each to a TypeScript
type you actually work with. A selection:
| MetafieldType.* | TypeScript type |
| --- | --- |
| SingleLineTextField, MultiLineTextField | string |
| NumberInteger, NumberDecimal | number |
| Boolean | boolean |
| Date, DateTime | string |
| Color | `#${string}` |
| Url, Id | string |
| Json | unknown |
| Money | { amount: string; currency_code: string } |
| Weight | { value: number; unit: WeightUnit } |
| Volume, Dimension | { value: number; unit: ... } |
| Link | { text: string; url: string } |
| Rating | { value: string; scale_min: string; scale_max: string } |
| RichTextField | RichTextRoot |
| ProductReference | ShopifyGid<"Product"> |
| VariantReference | ShopifyGid<"ProductVariant"> |
| MetaobjectReference | ShopifyGid<"Metaobject"> |
| CustomerReference | ShopifyGid<"Customer"> |
Reference types for articles, collections, companies, files, pages, and so on
follow the same pattern. Every scalar type has a List.* counterpart
(MetafieldType.List.NumberInteger, MetafieldType.List.MetaobjectReference,
and so on) whose value type is the array of the scalar.
To pull the inferred value type out of a schema, use InferFieldValues:
import {
type InferFieldValues,
type MetaobjectSchema,
MetafieldType,
} from "@by-association-only/metaobjects";
const registrySchema = {
title: MetafieldType.SingleLineTextField,
eventDate: { type: MetafieldType.Date, key: "event_date" },
shippingAddress: { type: MetafieldType.Json, key: "shipping_address" },
archived: MetafieldType.Boolean,
} as const satisfies MetaobjectSchema;
type RegistryFields = InferFieldValues<typeof registrySchema>;
// { title: string; eventDate: string; shippingAddress: unknown; archived: boolean }The as const satisfies MetaobjectSchema is the idiom: as const keeps the
literal type strings so inference works, satisfies checks the shape without
widening it.
Serialisation
Shopify stores every field as a string. serializeFields and
deserializeFields convert between typed values and that string format, and the
CRUD methods call them for you. The rules:
- Numbers and booleans become their string form on write (
3to"3",trueto"true") and are parsed back on read. Integers are rounded on read. - Structured types (
money,link,rating,weight,volume,dimension,rich_text_field,json) are JSON-encoded on write andJSON.parsed on read. - Lists are stored as a JSON array of the serialised scalar values.
nullandundefinedare dropped on serialise. They never reach Shopify, so a partial update only writes the keys you set.- An empty string deserialises to
null. A blank Shopify field comes back asnull, not"".
You rarely call these directly, but they are exported if you need to serialise outside a CRUD path:
import { serializeFields, deserializeFields } from "@by-association-only/metaobjects";
serializeFields(registrySchema, { title: "Wedding", archived: false });
// [{ key: "title", value: "Wedding" }, { key: "archived", value: "false" }]Subclassing
The returned class is a real class, so extend it to add domain behaviour. The
statics stay typed to the subclass: Registry.find(...) returns Registry with
your methods, not the base instance. This is deliberate. The runtime uses
new this late-binding so a subclass hydrates into its own type, and the static
signatures mirror that.
Modelled on abask's registry domain:
import {
attachToCustomer,
customerListConfig,
defineMetaobject,
type InferFieldValues,
MetafieldType,
type MetaobjectSchema,
} from "@by-association-only/metaobjects";
import type { AdminApiClient } from "@by-association-only/shopify-graphql-client";
import type { ShopifyGid } from "@shopify/admin-graphql-api-utilities";
export const registryType = "$app:registry" as const;
const registrySchema = {
title: MetafieldType.SingleLineTextField,
eventDate: { type: MetafieldType.Date, key: "event_date" },
shippingAddress: { type: MetafieldType.Json, key: "shipping_address" },
items: { type: MetafieldType.List.MetaobjectReference, optional: true },
} as const satisfies MetaobjectSchema;
type RegistryShippingAddress = {
line1: string;
city: string;
country: string;
postcode: string;
};
const customerRegistriesConfig = customerListConfig("registries");
export class Registry extends defineMetaobject(registryType, registrySchema) {
/** `shipping_address` is a json field; this is its one honest shape. */
get shippingAddress(): RegistryShippingAddress {
return this.fields.shippingAddress as RegistryShippingAddress;
}
async attachToCustomer(
client: AdminApiClient,
customerId: ShopifyGid<"Customer">,
): Promise<DomainVoidResult> {
return attachToCustomer(client, this.id, customerId, customerRegistriesConfig);
}
}Registry.find(client, id) now returns a Registry, so .shippingAddress and
.attachToCustomer are there. So does Registry.create(...).data,
Registry.all(...).nodes, and instance.update(...).data.
You can also override a static to add a guard. A subclass override calls
super.update but has to re-cast the result, because super.update types
against the base class rather than the subclass T:
static override async update<T extends typeof RegistryItemBase>(
this: T,
client: AdminApiClient,
id: string | number,
values: Partial<RegistryItemFields>,
status?: MetaobjectStatus,
): Promise<DomainResult<InstanceType<T>>> {
// ...validate, then:
return super.update(client, id, values, status) as Promise<
DomainResult<InstanceType<T>>
>;
}The registry
Every class from defineMetaobject self-registers under its type string in a
process-global map. You can look a class up by type without importing it
directly:
import {
getMetaobjectClass,
listRegisteredMetaobjectTypes,
} from "@by-association-only/metaobjects";
getMetaobjectClass("$app:store"); // the Store class, or undefined
listRegisteredMetaobjectTypes(); // ["$app:store", "$app:registry", ...]This is what lets a generic layer hydrate a node when it only knows the type
string at runtime, for instance a webhook handler or a serialisation boundary
that reconstructs instances from wire data. registerMetaobject(type, ctor) and
the BaseMetaobjectInstance base class are exported for that plumbing.
Customer-list joins
A metaobject is often joined to a customer through a
list.metaobject_reference metafield: a customer has many registries or many
wishlists, held as a list of metaobject GIDs on the customer. The customer-list
helpers read and write that list.
customerListConfig(key) builds a config pinned to the customer's $app
metafield at key. The attach and detach helpers take that config:
import {
attachToCustomer,
customerListConfig,
detachFromCustomer,
detachManyFromCustomer,
} from "@by-association-only/metaobjects";
const config = customerListConfig("registries");
// Append. Idempotent: no duplicate if the id is already in the list.
await attachToCustomer(client, registryId, customerId, config);
// Remove. Idempotent: a no-op if the id is absent.
await detachFromCustomer(client, registryId, customerId, config);
// Bulk remove: one read and one write for many ids belonging to one customer.
await detachManyFromCustomer(client, [id1, id2], customerId, config);Each returns a DomainVoidResult. The realistic shape is to wrap attach in a
domain method on the subclass, as Registry.attachToCustomer above does, so the
config and key live in one place.
reconcileChildren
reconcileChildren syncs a desired list of child metaobjects to Shopify. It is
generic over any parent/child shape (steps to options, registry to items,
sections to blocks), so you supply the operations. It splits the desired list
by id and runs three phases:
- Create every item whose id is not a metaobject GID (a temporary id such
as
temp_1marks a new child). - Update every item whose id is a real GID.
- Delete every GID in
currentIdsthat is absent from the desired list.
It returns the final ordered GID list, matching the order of desired.
There is no rollback. If a phase fails it stops and returns the errors; anything already created or updated in an earlier phase stays put. Fix the cause and reconcile again. Because create and update are idempotent against the same desired state, re-running converges.
You pass ops describing how to create, update, delete, and read ids for your
child type:
import {
reconcileChildren,
type ReconcileOps,
} from "@by-association-only/metaobjects";
import type { ShopifyGid } from "@shopify/admin-graphql-api-utilities";
type Child = { id: string; label: string };
const ops: ReconcileOps<Child, { id: ShopifyGid<"Metaobject"> }> = {
create: async (client, item) => {
const result = await ChildMetaobject.create(client, { label: item.label });
if (!result.ok) throw new Error(result.errors[0]?.message);
return { id: result.data.id };
},
update: async (client, id, item) => {
const result = await ChildMetaobject.update(client, id, { label: item.label });
if (!result.ok) throw new Error(result.errors[0]?.message);
return { id: result.data.id };
},
delete: async (client, id) => {
await ChildMetaobject.deleteMany(client, [id]);
},
getChildId: (item) => item.id,
getId: (result) => result.id,
};
// current holds the parent's existing child GIDs; desired is the new list,
// with temp ids for new children and real GIDs for the ones that survive.
const result = await reconcileChildren(
client,
currentChildIds,
[
{ id: existingChildGid, label: "kept and renamed" }, // updated
{ id: "temp_new", label: "brand new" }, // created
// any GID in currentChildIds not listed here is deleted
],
ops,
);
if (result.ok) {
result.data.ids; // ordered GIDs, matching the desired order
// write these back onto the parent's list.metaobject_reference field
}ops.create and ops.update throw on failure. reconcileChildren catches the
throw, stops the run, and returns the message as a DisplayableError.
Results and errors
Fallible operations return a discriminated result rather than throwing:
type DomainResult<T> =
| { ok: true; data: T }
| { ok: false; errors: DisplayableError[] };
type DomainVoidResult =
| { ok: true }
| { ok: false; errors: DisplayableError[] };
type DisplayableError = { field?: string[] | null; message: string };create, update (both static and instance), and reconcileChildren return
DomainResult. The customer-list helpers return DomainVoidResult. These are
made to be returned straight out of a server function: no wrapping needed.
deleteMany is different because a bulk delete can partially fail. It returns a
per-id breakdown:
type DeleteManyResult = {
ok: boolean; // true only if every id deleted
results: Array<
| { id: string; ok: true }
| { id: string; ok: false; error: DisplayableError }
>;
};Two helpers ease the display side:
import { firstError, toDisplayableErrors } from "@by-association-only/metaobjects";
// Pull one message out for a toast or banner.
if (!result.ok) toast(firstError(result));
// Turn raw strings into DisplayableError[].
toDisplayableErrors(["Something went wrong"]);
// [{ field: null, message: "Something went wrong" }]Gotchas
reconcileChildrendoes not roll back. A failed phase leaves earlier writes in place. Treat it as re-runnable, not transactional. Fix the cause and call it again.Empty string deserialises to
null. A blank Shopify field comes back asnulleven for a field the schema types as required (string). The type saysstring; the runtime value can benull. Guard fields that might be blank.deleteManyreturnsDeleteManyResult, notDomainResult. Checkresult.okfor the all-or-nothing view, and walkresult.resultsto find the ids that failed. Do not reach forresult.dataorresult.errors; they are not there.optionalhas to be the literaltrue.{ optional: true }, notoptional: someBoolean. The split between required and optional keys is driven by the literaltrue, so a widened boolean breaks it.Admin types come from a versioned subpath.
MetaobjectStatus,DisplayableError, and the rest of the Admin schema types come from@by-association-only/shopify-admin-types/2026-07. The schema version is pinned in the subpath; there is no root export. This package re-exportsMetaobjectStatusandDisplayableErrorfrom its own barrel, so import them from@by-association-only/metaobjectsand you get the pinned version without naming the subpath yourself.A subclass sent over a serialisation boundary must re-register itself.
defineMetaobjectregisters the anonymous base class it returns. When your subclass adds prototype members (a getter, a method) and instances cross a boundary that reconstructs them from the registry (for example TanStack Start server functions, where a class instance is serialised and rebuilt on the other side), the rebuild picks the registered class. If that is the base and not your subclass, the prototype members vanish. abask'sRegistryhandles this by re-registering the subclass under the same type after declaring it:import { registerMetaobject } from "@by-association-only/metaobjects"; export class Registry extends defineMetaobject(registryType, registrySchema) { get shippingAddress() { /* ... */ } } // Re-register the subclass so wire-deserialised instances keep Registry's // members. Without this the `shippingAddress` getter vanishes on the client. registerMetaobject(registryType, Registry);Plain data access (
.fields,.id) survives regardless; only the added prototype members are at risk, so you only need this when the subclass has them and its instances cross such a boundary.
