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.
The chain
Section titled “The chain”| 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.
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>;Dimensions: four forms
Section titled “Dimensions: four forms”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.
Pools: feeds
Section titled “Pools: feeds”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
feedsitself, and is declared before the meters that feed it (A89). The type system only accepts such ids asfeedskeys. - The weight is a number or a
(dims, ctx) => numberfunction. The pool line’s quantity isceil(quantity × weight)with float noise removed: values within a relative 1e-9 of an integer snap to it (10 × 1.1is 11, not 12; A90, A104). - The pool line gets its own
getRateanswer 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: [].
Type errors
Section titled “Type errors”// @ts-expect-error — "msg/unknown" is not a pool declared earlierbuildMetering().meter("msg/free_pool").meter("otp/sms", ["country"], { feeds: { "msg/unknown": 1 } });
// @ts-expect-error — observe needs getRate and commit firstcatalog.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().
getRate, commit and bind
Section titled “getRate, commit and bind”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 () => {} });getRatereturns the whole tariff in force, not a single unit price. It may return a raw object; the library validates it withvalidateRateand returnsinvalid_rateif it fails.null/undefinedmeans no price →ifMissing.- Within one call,
getRateruns once per distinct(meter, dims). binddoes 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).
Context and ref validation
Section titled “Context and ref validation”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 whatgetRateandfeedsfunctions receive. Metering itself reads onlyaccountId, which must always be a string. refs()takes an array, a schema, or a type only. With an array or schema,ref.typeis checked and a mismatch returnsinvalid_ref(A86).ref.idmust always be a string.
Not in the catalog
Section titled “Not in the catalog”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).
Size and speed
Section titled “Size and speed”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.