Buckets
Overview
What Buckets is, the core idea behind it, and a motivating example.
Buckets is a rule engine for sorting items into named buckets. You declare named conditions over an item, then declare buckets as boolean expressions over those conditions. That way, the expensive, awkward part (is this thing digital? does this account pay us?) is written once and answered once per item, and the rules on top read like the sentences you’d use to describe them.
import { BucketEngine } from "@michaelrwalker/buckets";
import { z } from "zod";
const fulfillment = new BucketEngine()
.defineInput(
z.object({
sku: z.string(),
weightKg: z.number().nullable(),
downloadUrl: z.string().nullable(),
}),
)
.defineCondition({ name: "hasWeight", checkFn: (p) => p.weightKg !== null })
.defineCondition({
name: "isDownloadable",
checkFn: (p) => p.downloadUrl !== null,
})
.defineBucket({ name: "warehouse", checkFn: () => "hasWeight" })
.defineBucket({ name: "digitalDelivery", checkFn: () => "isDownloadable" })
.defineBucket({
name: "purelyPhysical",
checkFn: ({ ONLY }) => ONLY("hasWeight"),
})
.defineBucket({
name: "incomplete",
checkFn: ({ OR, NOT }) => OR(NOT("hasWeight"), NOT("isDownloadable")),
});
const report = await fulfillment.process(catalog);
report.buckets.warehouse; // Product[]
Condition names, bucket names and the shape of the report are all inferred.
The combinators handed to checkFn are bound to your condition names, so a
typo like NOT("hasWieght") is a compile error on the name itself, rather
than a rule that silently never fires.
Buckets are independent rules
An item lands in every bucket whose expression it satisfies. A vinyl
record with a download code is in both warehouse and digitalDelivery,
because it genuinely needs both. Summing the buckets can exceed the size of
the batch, and that isn’t a bug — it’s the point: Buckets doesn’t partition
your data, it answers a separate yes/no question per bucket.
What’s guaranteed is that no item disappears: every one ends up in at least
one bucket, in unmatched, or in errors.
Conditions a rule doesn’t mention are free. checkFn: () => "hasWeight"
matches anything with a weight, whatever else is true of it. When you want
the strict reading — a weight and nothing else — ONLY gives it to you:
ONLY("hasWeight").
Why not just write the if-statements
Because the awkward part of a rule engine is rarely the boolean logic: it’s
keeping the conditions and the rules that use them in sync as both grow. A
hand-written if chain re-evaluates the same predicate in every branch that
needs it, and there is nothing stopping two branches from disagreeing about
what “digital” means. Buckets separates the two: conditions are evaluated
once per item, in parallel, before any rule runs, and a bucket only ever
refers to a condition by name. Get a name wrong and the type checker — not a
runtime surprise three buckets later — tells you.
The engine also carries type information through the rules. A condition
written as a type predicate ((item): item is X => ...) tells the engine
what it proves, and the combinators propagate that through AND, OR and
NOT, so a bucket built from type predicates hands back the narrow type
instead of the engine’s input type. See the Type Narrowing guide for what
that buys you.
Where to go next
- Installation: add the package and the one thing it needs at runtime.
- Quick Start: a complete worked example, end to end.
- Defining Conditions: the building block everything else is made of.