Skip to content

Catalog

The catalog is one object and one chain; there is no build(). Every step returns the same object with a narrower type, so always use the returned reference. The catalog knows meter ids, dimension schemas and pool feeds; a meter has no unit, type, period or policy.

Step What it does Type effect
.context(schema | typed<C>()) or .context<C>() Context schema; validated on every bound call. Disable with { validate: "never" } Ctx
.refs(schema | ["a", "b"] | typed<R>()) or .refs<R>() The allowed ref.type values Ref.type union
.meter(id, dims?, { feeds? }) Adds a meter adds to MeterId, derives DimsOf<id>
.getRate(fn) (meter, dims, ctx, at) => { rate, usedSoFar?, carry? } | null bound flag
.commit(fn) (plan) => void; a throw becomes commit_failed, an InsufficientCredit throw becomes insufficient_credit bound flag
.bind({ getRate, commit }) A new bound copy of the same catalog both

Always available: meters, price(), validateRate(). Once both getRate and commit are given: observe, hold, extend, capture, release, commit(plan) and plan.*. Calling observe before binding is a compile error.

examples/messaging/src/catalog.ts
import { buildMetering, type Metering, type PricedLineOf, type RefOf } from "pricemeter";
import { z } from "zod";
export const Context = z.object({
accountId: z.string(),
plan: z.enum(["free", "pro", "enterprise"]),
});
export type Context = z.infer<typeof Context>;
const country = z.string().length(2);
export const catalog = buildMetering()
.context(Context)
.refs(["otp_send", "wa_window", "month"])
// pool first: every message, whatever the channel, counts toward the monthly free platform quota
.meter("msg/free_pool")
.meter("otp/sms", { country }, { feeds: { "msg/free_pool": 1 } })
.meter("otp/whatsapp", { country }, { feeds: { "msg/free_pool": 1 } })
.meter("otp/verify")
.meter("wa/window", { country })
.meter("ip/dedicated");
export type Catalog = typeof catalog;
type State = Catalog extends Metering<infer S> ? S : never;
/** A priced line of this catalog, as `plan.observe(...).lines` returns it. */
export type CatalogLine = PricedLineOf<State>;
export type CatalogRef = RefOf<State>;
docs/src/snippets/catalog.test.ts
import { buildMetering, typed, type Rate } from "pricemeter";
const catalog = buildMetering()
.context(z.object({ accountId: z.string(), plan: z.enum(["free", "pro"]) }))
.refs(["otp_send", "period"])
// 1. a record of Standard Schemas: typed and validated on every call
.meter("otp/sms", { country: z.string().length(2) })
// 2. one object schema (zod, valibot, arktype…): typed and validated
.meter("cc/conversation", v.object({ tier: v.picklist(["short", "medium", "long"]) }))
// 3. typed<T>(): typed, never validated
.meter("storage/gb", typed<{ region: "eu" | "us" }>())
// 4. a key array: values are `any`, never validated
.meter("legacy/api", ["endpoint"])
// no dims at all
.meter("ip/dedicated");
Form Type Runtime validation
Record of schemas { country: z.string() } from the schemas every call, per field; unknown keys fail
Object schema z.object({…}), v.object({…}) from the schema every call
typed<T>() T none
Key array ["country"] values are any none

Dimension values can be strings, numbers or enums; there are no wildcards (wildcard matching belongs in getRate, see /rates). A failed validation returns { ok: false, reason: "invalid_dims", meter, cause }. The library depends on no schema library; it only calls ~standard.validate.

docs/src/snippets/catalog.test.ts
const withPools = buildMetering()
.context(z.object({ accountId: z.string(), plan: z.enum(["free", "pro"]) }))
.refs(["otp_send", "request"])
.meter("msg/free_pool") // a pool is an ordinary meter: no dims, no feeds, declared first
.meter("llm/credits")
.meter("otp/sms", { country: z.string().length(2) }, { feeds: { "msg/free_pool": 1 } })
.meter("otp/whatsapp", { country: z.string().length(2) }, { feeds: { "msg/free_pool": 1 } })
// weight as a function of (dims, ctx): opus output counts 5 credits per token, the rest 3
.meter("llm/output", { model: z.string() }, { feeds: { "llm/credits": (d, _ctx) => (d.model === "opus" ? 5 : 3) } });
withPools.meters["otp/sms"]; // { dims: ["country"], feeds: ["msg/free_pool"] }
  • A pool is an ordinary meter: dimensionless, takes no feeds itself, and is declared before the meters that feed it (A89). The type system only accepts such ids as feeds keys.
  • The weight is a number or a (dims, ctx) => number function. The pool line’s quantity is ceil(quantity × weight) with float noise removed: values within a relative 1e-9 of an integer snap to it (10 × 1.1 is 11, not 12; A90, A104).
  • The pool line gets its own getRate answer and writes its own usage row (detail.feeder, detail.weight). If you pass the pool line yourself in the same call, it is not added twice; { feeds: false } turns expansion off.

metering.meters is a flat, serializable view: { "otp/sms": { dims: ["country"], feeds: ["msg/free_pool"] } }. Dimension keys are read from a record of schemas, a key array, or an object schema (zod .shape, valibot .entries, arktype .props); typed<T>() shows dims: [].

docs/src/snippets/catalog.test.ts
// @ts-expect-error — "msg/unknown" is not a pool declared earlier
buildMetering().meter("msg/free_pool").meter("otp/sms", ["country"], { feeds: { "msg/unknown": 1 } });
// @ts-expect-error — observe needs getRate and commit first
catalog.observe("otp/sms", { country: "TR" }, 1, { type: "otp_send", id: "1" }, { accountId: "a", plan: "pro" });

The same rules are checked at runtime: an undeclared pool, a pool with dims, a meter with feeds used as a pool, and a duplicate id all throw in meter().

docs/src/snippets/catalog.test.ts
const sms: Rate = { model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 20_000 }] };
const metering = withPools
// declare all four parameters, even unused ones (A94)
.getRate(async (meter, dims, _ctx, _at) => {
if (meter === "otp/sms") {
dims.country; // string — dims narrow on meter
return { rate: sms };
}
return null; // no price → ifMissing
})
.commit(async (_plan) => {
/* your transaction */
});
// The same catalog with other adapters: a new bound copy; `withPools` itself is unchanged.
const forTests = withPools.bind({ getRate: async (_m, _d, _c, _a) => null, commit: async () => {} });
  • getRate returns the whole tariff in force, not a single unit price. It may return a raw object; the library validates it with validateRate and returns invalid_rate if it fails. null/undefined means no price → ifMissing.
  • Within one call, getRate runs once per distinct (meter, dims).
  • bind does not change the catalog; it returns a new bound copy. A shared catalog (e.g. used by both a Worker and a Durable Object) stays adapter-free and each side binds its own adapters (messaging example).
docs/src/snippets/catalog.test.ts
await metering.observe("otp/sms", { country: "TUR" }, 1, { type: "otp_send", id: "1" }, ctx);
// → { ok: false, reason: "invalid_dims", meter: "otp/sms", cause: [zod issues] }
await metering.observe("otp/sms", { country: "TR" }, 1, { type: "otp_send", id: "1" }, { accountId: "a", plan: "gold" } as never);
// → { ok: false, reason: "invalid_context", cause: [zod issues] }
await metering.observe("otp/sms", { country: "TR" }, 1, { type: "refund", id: "1" } as never, ctx);
// → { ok: false, reason: "invalid_ref", cause: 'unknown ref type "refund"' }
  • The context is validated against its schema on every bound call (D17/A73); the schema’s output (coerce, default) is what getRate and feeds functions receive. Metering itself reads only accountId, which must always be a string.
  • refs() takes an array, a schema, or a type only. With an array or schema, ref.type is checked and a mismatch returns invalid_ref (A86). ref.id must always be a string.

unit (presentation), type (an observation has one shape: a quantity), aggregation / period / chargeTiming / batch (the application’s timing), missingPolicy (an observe option: ifMissing), holdable (every meter can be held), costBearing / counted (application metadata).

A 500-meter catalog type-checks in ~0.6 s (bun run test:bench-types, budget 2 s). Each step writes its type state as an alias-free MeteringState<…> (A93). The practical chain limit is ~800 .meter() calls; the TypeScript parser overflows its stack at 1,000 chained calls (A96). Larger catalogs can be split.