Business Rules Library · TypeScript

Business rules that compose into
a language you can reason about.

Complex conditional logic is hard to read, impossible to explain, and painful to test. rulewrite turns each business condition into a named, typed predicate — composable with logical operators and evaluable as a complete diagnostic tree.

🏷️
Named predicates Every rule carries a label that appears in evaluation output, audit logs, and user-facing error messages.
🔗
Free composition AND, OR, NOT, IMPLIES, PREVENTS — compose any rules into any policy. Every result is itself a rule.
🌳
Complete evaluation trees Ask a rule to explain itself. Get a full tree showing exactly which conditions passed and which failed.
$ npm install @daltonr/rulewrite
Live rule evaluation runs in-browser
SATISFIED
5
Logical operators
0
Dependencies
2
Evaluation modes
100%
Type safe

The cost of scattered conditionals

Business logic accumulates. What starts as a simple check becomes an unreadable wall of booleans — untestable, unexplainable, and impossible to reuse.

✗   The status quo
checkout.ts
if (
  user.status === 'active' &&
  user.age >= 18 &&
  user.emailVerified &&
  !product.archived &&
  product.stockLevel > 0 &&
  product.countries.includes(user.country) &&
  (
    product.price <= user.creditLimit ||
    user.roles.includes('admin')
  )
) {
  // can this user even check out?
  // which condition blocked them?
  // can we test this independently?
  allowCheckout();
}

Not testable in isolation. Not reusable across the codebase. Silent on failure — when it returns false, you don't know why.

✓   With rulewrite
checkout/policy.ts
const canCheckout = all(
  customerIsAdult,
  customerIsVerified,
  customerIsActive,
  productIsInStock,
  isAvailableInCountry,
  isWithinCreditLimit,
);

const result = canCheckout.evaluate(ctx);
// {
//   satisfied: false,
//   label: 'ALL',
//   children: [
//     { satisfied: true,  label: 'IsAdult' },
//     { satisfied: false, label: 'IsEmailVerified' },
//     ...
// }

Each condition is named and independently testable. Evaluation tells you exactly which rule failed. The same rules are reusable across every policy in your codebase.

Three steps. Unlimited power.

Define atomic rules over your domain types. Compose them into policies using logical operators. Evaluate to get a boolean or a complete diagnostic tree.

Step 1 — Define

Named predicates

Each business condition becomes a named, typed rule. One condition, one rule, one name that appears everywhere it is used.

import { rule } from '@daltonr/rulewrite';

const isAdult = rule<User>(
  u => u.age >= 18,
  'IsAdult'
);

const isVerified = rule<User>(
  u => u.emailVerified,
  'IsEmailVerified'
);
Step 2 — Compose

Logical operators

Combine rules with AND, OR, NOT, IMPLIES, and PREVENTS. Every result is a rule — compose without limit.

// Simple composition
const canRegister =
  isAdult.and(isVerified);

// Conditional requirement
const agePolicy =
  isAgeRestricted.implies(isVerified);

// Flat list with all()
const canCheckout = all(
  customerIsAdult,
  productIsInStock,
  isWithinCreditLimit,
);
Step 3 — Evaluate

Boolean or full tree

isSatisfiedBy() for fast checks. evaluate() for the complete diagnostic tree — every sub-rule, every outcome.

// Fast boolean check
canRegister.isSatisfiedBy(user);
// → false

// Full diagnostic tree
canRegister.evaluate(user);
// {
//   satisfied: false,
//   label: 'AND',
//   children: [
//     { satisfied: true,  label: 'IsAdult' },
//     { satisfied: false, label: 'IsEmailVerified' }
//   ]
// }

Everything a rules library should do

From a single atomic predicate to a production-grade access control policy — rulewrite scales with you.

🏷️

Named predicates

Every rule carries a label. Labels appear in evaluation trees, audit logs, and user-facing messages — no more silent failures.

⚙️

Five logical operators

AND, OR, NOT, IMPLIES, and PREVENTS. IMPLIES handles conditional requirements precisely — things AND gets wrong.

🎯

Context projection

Define rules over their natural type. Use .on() to lift them into any wider context without coupling or duplication.

📦

Collection combinators

someOf, allOf, noneOf — apply any rule across a collection. Works with implications for powerful group policies.

🌳

Complete evaluation trees

Every evaluate() call returns a recursive tree. Walk it to generate error messages, build audit logs, or render diagnostics.

🔷

Full type safety

Rules carry their subject type. The compiler prevents composing rules over incompatible types before you run a single test.

📭

Zero dependencies

No runtime dependencies. Nothing to audit, nothing to update, nothing that breaks. Just TypeScript.

🧪

Designed for testing

Pure functions of their inputs. Test each atomic rule in isolation. No mocking, no setup, no framework required.

See it in action

From a single rule to a complete access control policy in minutes.

users/rules.ts
import { rule } from '@daltonr/rulewrite';

interface User {
  age: number;
  emailVerified: boolean;
  status: 'active' | 'suspended';
  roles: string[];
}

// Each rule is a named predicate over a single type.
// The label is used in evaluation output and audit logs.
export const isAdult = rule<User>(
  u => u.age >= 18,
  'IsAdult'
);

export const isEmailVerified = rule<User>(
  u => u.emailVerified,
  'IsEmailVerified'
);

export const isActive = rule<User>(
  u => u.status === 'active',
  'IsAccountActive'
);

export const isAdmin = rule<User>(
  u => u.roles.includes('admin'),
  'IsAdmin'
);
checkout/policy.ts
import { all, rule } from '@daltonr/rulewrite';
import { isAdult, isEmailVerified, isActive } from '../users/rules';
import { isInStock, requiresPrescription } from '../products/rules';

type CheckoutContext = { customer: User; product: Product };

// Project user rules into the checkout context
const customerIsAdult    = isAdult.on((ctx: CheckoutContext) => ctx.customer);
const customerIsVerified = isEmailVerified.on((ctx: CheckoutContext) => ctx.customer);
const customerIsActive   = isActive.on((ctx: CheckoutContext) => ctx.customer);
const productIsInStock   = isInStock.on((ctx: CheckoutContext) => ctx.product);

// IMPLIES: prescription only required for prescription products
const prescriptionPolicy = requiresPrescription
  .on(ctx => ctx.product)
  .implies(hasPrescription.on(ctx => ctx.customer));

// all() produces a flat evaluation tree — easier to read than chaining
export const canCheckout = all(
  customerIsAdult,
  customerIsVerified,
  customerIsActive,
  productIsInStock,
  prescriptionPolicy,
);
checkout/handler.ts
import { EvaluationResult } from '@daltonr/rulewrite';

// Walk the tree to collect every failed leaf node
function failures(result: EvaluationResult): string[] {
  if (!result.satisfied && !result.children?.length) return [result.label];
  return (result.children ?? []).flatMap(failures);
}

// Message catalogue: labels map to user-facing text
const messages: Record<string, string> = {
  IsAdult:         'You must be 18 or older to purchase this item.',
  IsEmailVerified: 'Please verify your email address before checking out.',
  IsAccountActive: 'Your account has been suspended.',
  IsInStock:       'This item is currently out of stock.',
};

async function processCheckout(ctx: CheckoutContext) {
  const result = canCheckout.evaluate(ctx);

  // Log the full tree for audit — permanently records why the decision was made
  await auditLog.record({ action: 'checkout_attempt', policy: result });

  if (!result.satisfied) {
    throw new ValidationError(
      failures(result).map(label => messages[label])
    );
  }

  await completeCheckout(ctx);
}
authorization/policy.ts
type AuthContext = { user: User; document: Document };

// Atomic rules over each type
const isActive    = rule<User>(u => u.status === 'active', 'IsActive');
const isAdmin     = rule<User>(u => u.roles.includes('admin'), 'IsAdmin');
const isArchived  = rule<Document>(d => d.archived, 'IsArchived');

// Cross-type rules written directly against the context
const isOwner = rule<AuthContext>(
  ctx => ctx.document.ownerId === ctx.user.id, 'IsOwner'
);

// Project rules into the shared context
const userIsActive   = isActive.on((ctx: AuthContext) => ctx.user);
const userIsAdmin    = isAdmin.on((ctx: AuthContext) => ctx.user);
const docIsArchived  = isArchived.on((ctx: AuthContext) => ctx.document);

// Readable, testable, self-documenting policies
export const canRead    = userIsActive.and(isOwner.or(userIsAdmin));
export const canEdit    = userIsActive.and(isOwner.or(userIsAdmin)).and(docIsArchived.not());
export const canDelete  = userIsActive.and(isOwner.or(userIsAdmin));
export const canPublish = userIsActive.and(isOwner.or(userIsAdmin)).and(docIsArchived.not());

Real-world applications

rulewrite fits anywhere complex decisions are made and explained.

Access Control · RBAC

Document permissions

Define read, edit, delete, and publish policies as named rules. Every denied request tells you exactly which condition was not met.

canEdit = isActive.and(isOwner.or(isAdmin)).and(not(isArchived))
canPublish = isActive.and(isOwner.or(isModerator)).and(not(isPublished))
E-commerce · Checkout

Purchase eligibility

Combine customer rules, product rules, and cross-type conditions into a single policy. Use IMPLIES for conditional requirements like prescription checks.

canCheckout = all(isAdult, isVerified, isInStock, withinCreditLimit)
prescriptionPolicy = requiresPrescription.implies(hasPrescription)
Compliance · Validation

Venue attendance rules

Collection combinators and IMPLIES combine naturally. "If any attendee is a minor, an adult must also be present" — one readable expression.

policy = anyChildAttending.implies(anyAdultAttending)
anyChildAttending = isChild.someOf(ctx => ctx.attendees)
Finance · Eligibility

Loan application checks

Express complex eligibility criteria as named, auditable rules. The evaluation tree becomes the permanent record of why an application was approved or declined.

canApply = all(isAdult, isResident, meetsIncomeThreshold, noDefaultHistory)
audit log = canApply.evaluate(applicant)
API · Middleware

Express / Fastify guards

Pass any policy to a middleware factory. Failed requests return structured error bodies listing every violated condition by name — useful for API consumers.

router.put('/docs/:id', requiresPermission(canEdit), handler)
403 body: { reasons: ['IsAccountActive', 'IsOwner'] }

How rulewrite compares

No other approach combines composability, type safety, and self-describing evaluation in a zero-dependency TypeScript package.

rulewrite Raw booleans Zod / Yup Hand-rolled Spec
Named, reusable rules Schemas only If you build it
Free composition (AND, OR, NOT) && / || / ! Limited If you build it
IMPLIES operator If you build it
PREVENTS operator (aka NAND) If you build it
Context projection (.on()) If you build it
Collection combinators Array schemas If you build it
Evaluation tree (explains why) Error messages If you build it
Full TypeScript type safety Varies
Zero dependencies
Test rules in isolation Schema tests Varies
Audit log ready If you build it

Documentation

From first rule to production rule set — everything is documented.

Start writing rules that explain themselves.

Replace scattered conditionals with named, composable, auditable business rules. Zero dependencies. Pure TypeScript.

npm install @daltonr/rulewrite
Read the quickstart View on GitHub