npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/metaobjects

Everything 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-client

The 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 (3 to "3", true to "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 and JSON.parsed on read.
  • Lists are stored as a JSON array of the serialised scalar values.
  • null and undefined are 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 as null, 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:

  1. Create every item whose id is not a metaobject GID (a temporary id such as temp_1 marks a new child).
  2. Update every item whose id is a real GID.
  3. Delete every GID in currentIds that 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

  • reconcileChildren does 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 as null even for a field the schema types as required (string). The type says string; the runtime value can be null. Guard fields that might be blank.

  • deleteMany returns DeleteManyResult, not DomainResult. Check result.ok for the all-or-nothing view, and walk result.results to find the ids that failed. Do not reach for result.data or result.errors; they are not there.

  • optional has to be the literal true. { optional: true }, not optional: someBoolean. The split between required and optional keys is driven by the literal true, 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-exports MetaobjectStatus and DisplayableError from its own barrel, so import them from @by-association-only/metaobjects and you get the pinned version without naming the subpath yourself.

  • A subclass sent over a serialisation boundary must re-register itself. defineMetaobject registers 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's Registry handles 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.