Buckets

Defining Conditions

How defineInput and defineCondition work, and the ordering rules the engine enforces.

Every rule in Buckets is built on two things: the shape of an item (defineInput) and the named predicates you check against it (defineCondition). Both come before any bucket.

defineInput

Call this first, before any condition. It has two forms.

With a schema

import { BucketEngine } from "@michaelrwalker/buckets";
import { z } from "zod";

const engine = new BucketEngine().defineInput(
  z.object({
    sku: z.string(),
    weightKg: z.number().nullable(),
    downloadUrl: z.string().nullable(),
  }),
);

defineInput(schema) infers the item type from a Standard Schema’s output type, and validates every item at runtime before any condition sees it. A schema that transforms its input (trimming a string, coercing a type) means conditions and the final report both see the transformed value, not the raw one.

Any Standard Schema–compliant library works: Zod, Valibot, ArkType, Effect Schema. Buckets depends only on the @standard-schema/spec types, not on any particular library.

Types only

const engine = new BucketEngine().defineInput<Product>();

defineInput<Product>() declares the item type without validating anything at runtime. Use this when you already trust the data — the batch came from your own database, say — and just want the type safety. Because nothing is validated, report.errors can then only ever contain condition failures, never stage: "input" entries.

Ordering

Calling defineInput twice, or calling it after a condition has already been defined, throws a BucketError. Conditions are typed against whatever TInput was when they were declared, so defining them before the input type exists would leave them typed against never: the runtime enforces the ordering to match what the types already require.

defineCondition

A condition is a name and a predicate:

const withConditions = engine
  .defineCondition({
    name: "hasWeight",
    checkFn: (product) => product.weightKg !== null,
  })
  .defineCondition({
    name: "isDownloadable",
    checkFn: (product) => product.downloadUrl !== null,
  });
OptionTypeNotes
namestringBecomes part of the engine’s type. Must be unique.
checkFn(item) => boolean | Promise<boolean>May be async. The return value is coerced with Boolean(). Throwing sends that one item to report.errors instead of the whole batch.

name becomes a literal in the engine’s type parameters, which is what lets AND, OR, NOT and ONLY in a later bucket autocomplete your condition names and reject a typo at compile time.

Every condition runs once

Every condition is evaluated once per item, before any bucket runs, so a condition five different buckets depend on still costs exactly one call per item. Conditions with nothing gating them run concurrently; a condition declared with when (see the Preconditions guide) waits on whatever it depends on and can skip its checkFn entirely when the precondition doesn’t hold — but it’s still evaluated at most once. This is also why conditions have to be fully declared before the first bucket: a bucket’s ONLY rule means “these and nothing else”, and that only makes sense once the complete set of conditions is known.

Ordering rules

Define all plain conditions first: before the first computed condition (defineComputedCondition) and before the first bucket (defineBucket). Trying to add a condition afterward throws a BucketError, because adding it later would silently change what an existing ONLY rule matches.

Condition names and computed condition names share one namespace, so a defineCondition call with a name already used by a condition or a computed condition also throws.

Writing checkFn

The simplest form returns a plain boolean:

.defineCondition({ name: "inStock", checkFn: (listing) => listing.stock > 0 })

It can be async, which is useful when deciding a condition means an I/O call:

.defineCondition({
  name: "payingCustomer",
  checkFn: (ticket) => isPaidAccount(ticket.accountId),
})

See the Async Conditions and Performance guide for how the engine bounds concurrency across a batch of async conditions, and what happens when one throws.

Type predicates

Writing checkFn as a type predicate — (item): item is X => ... — tells the engine what the condition proves, not just whether it holds:

.defineCondition({
  name: "isString",
  checkFn: (v): v is string => typeof v === "string",
})

Buckets built from a condition like this hand back the narrow type rather than the engine’s input type. This is entirely opt-in: an ordinary boolean predicate still works exactly as before, it just narrows nothing. See the Type Narrowing guide for the full story, including how narrowing composes through AND, OR and NOT.

Gating on other conditions

A condition can depend on another one directly, with when: checkFn is skipped entirely when the precondition doesn’t hold, and can see the narrower type the precondition proved.

.defineCondition({
  name: "hasProduct",
  checkFn: (item): item is Listing & { product: Product } =>
    item.product !== undefined,
})
.defineCondition({
  name: "hasWeight",
  when: () => "hasProduct",
  // item.product is Product here, not Product | undefined.
  checkFn: (item) => item.product.weightKg !== null,
})

See the Preconditions guide for the different ways to write when, and the narrowing/autocomplete tradeoff each one makes.