A dependency-free, type-safe validation engine for composing complex business rules with elegant pipelines.
// Example: E-commerce checkout
const validateCheckout = pipeRules([
validateCart,
match(paymentMethod, {
'credit_card': validateCreditCard,
'paypal': validatePaypal
}),
withRetry(validateInventory, { attempts: 3 })
]);
const result = await validateCheckout(order);
- 🧩 Composable - Build pipelines with 25+ combinators (
when
,unless
,mapError
, etc.) - 🚀 Zero Dependencies - Lightweight (under 5KB gzipped)
- 🦾 TypeScript First - Full inference for inputs, contexts, and errors
- ⏱ Async Ready - Mix sync/async rules seamlessly
- 🛡 Context-Aware - Shared validation state
- 🔌 Extensible - Easily create custom combinators that integrate seamlessly
- 📊 Instrumentation - Debugging & metrics out-of-the-box
npm install ts-rules-composer
# or
yarn add ts-rules-composer
# or
pnpm add ts-rules-composer
The atomic validation unit:
type Rule<TInput, TError = string, TContext = unknown> = (
input: TInput,
context?: TContext
) => Promise<RuleResult<TError>> | RuleResult<TError>;
Always returns:
type RuleResult<TError> =
| { readonly status: "passed" }
| { readonly status: "failed"; readonly error: TError };
import {
pipeRules,
match,
withRetry,
withMemoize,
withTimeout,
when
} from 'ts-rules-composer';
// 1. Basic validators
const validateAmount = (tx: Transaction) =>
tx.amount > 0 ? pass() : fail("Amount must be positive");
const validateCurrency = (tx: Transaction) =>
SUPPORTED_CURRENCIES.includes(tx.currency)
? pass()
: fail(`Unsupported currency: ${tx.currency}`);
// 2. Memoized fraud check (cached for 5 minutes)
const checkFraudRisk = withMemoize(
async (tx: Transaction) => {
const risk = await fraudService.assess(tx);
return risk.score < 5 ? pass() : fail("High fraud risk");
},
tx => `${tx.userId}-${tx.amount}-${tx.recipient}`,
{ ttl: 300000 }
);
// 3. Payment method handling
const validatePaymentMethod = match(
tx => tx.paymentType,
{
"credit_card": pipeRules([
validateCardNumber,
validateExpiry,
withTimeout(bankAuthCheck, 3000, "Bank auth timeout")
]),
"crypto": validateWalletAddress,
"bank_transfer": validateIBAN
}
);
// 4. Complete pipeline
const validateTransaction = pipeRules([
validateAmount,
validateCurrency,
// Only run fraud check for transactions > $1000
when(
tx => tx.amount > 1000,
withRetry(checkFraudRisk, { attempts: 2 })
),
validatePaymentMethod,
// Compliance check (different for business/personal)
match(
tx => tx.accountType,
{
"business": validateBusinessTransfer,
"personal": validatePersonalTransfer
}
)
]);
// Usage
const result = await validateTransaction(paymentRequest, {
userKycStatus: await getKycStatus(userId)
});
import { pipeRules, every, match, withMetrics } from 'ts-rules-composer';
const validateUser = pipeRules([
// Sequential validation
validateUsernameFormat,
withMemoize(checkUsernameAvailability, { ttl: 60000 }),
// Parallel validation
every([
validatePasswordStrength,
validateEmailFormat,
checkEmailUnique
]),
// pattern matching routing
match(
user => user.role,
{
'admin': validateAdminPrivileges,
'user': validateStandardUser
},
fail('Unknown role')
)
]);
// With instrumentation
const instrumentedValidation = withMetrics(validateUser, {
onEnd: (result) => trackAnalytics(result)
});
const bookAppointment = pipeRules([
// Context-aware validation
requireContextRule(
"Missing schedule data",
(appt, ctx: ScheduleContext) => validateDoctorAvailability(appt, ctx)
),
// Error transformation
mapError(
validateInsurance,
error => `Insurance error: ${error.code}`
),
// Time-sensitive check
withTimeout(
checkFacilityCapacity,
5000,
"System timeout"
)
]);
// Usage
const result = await bookAppointment(newAppointment, clinicSchedule);
import {
pipeRules,
validateField,
requireContextRule,
inject,
tap
} from 'ts-rules-composer';
// 1. Strongly typed context
type ModerationContext = {
moderatorId: string;
permissions: string[];
environment: 'staging' | 'production';
};
// 2. Rule factory with dependency injection
const createModerationRules = (logger: LoggerService) =>
pipeRules([
// Enforce context requirements
requireContextRule(
"Moderator context required",
(post, ctx: ModerationContext) => pipeRules([
// Core validations
validateField(post => post.content, content =>
content.length > 0 || fail("Empty content")
),
// Environment-specific rule
when(
(_, ctx) => ctx.environment === 'production',
validateField(post => post.tags, tags =>
tags.length <= 5 || fail("Too many tags")
)
),
// Permission-based rule
when(
(_, ctx) => ctx.permissions.includes('FLAG_SENSITIVE'),
validateField(post => post.content, checkSensitiveContent)
),
// Logging side effect
tap((post, ctx) => {
logger.log({
action: 'moderate',
moderator: ctx.moderatorId,
postId: post.id
});
})
])
)
]);
// 3. Inject dependencies
const moderatePost = inject(
{ logger: new Logger() },
createModerationRules
);
// 4. Usage
const postModerationResult = await moderatePost(
{ id: "post123", content: "Hello", tags: ["a"] },
{
moderatorId: "user456",
permissions: ["FLAG_SENSITIVE"],
environment: "production"
}
);
Sequentially executes rules left-to-right (fails fast)
const validateUser = pipeRules([
validateEmailFormat,
checkEmailUnique,
validatePassword
], { cloneContext: true })
Sequentially executes rules right-to-left (fails fast)
const processInput = composeRules([
normalizeData,
validateInput,
sanitizeInput
])
Runs rules in parallel (collects all errors)
const validateProfile = every([
validateAvatar,
validateBio,
validateLinks
])
Pattern matching routing
const validatePayment = match(
order => order.payment.type,
{
credit: validateCreditCard,
paypal: validatePaypal,
crypto: validateCrypto
},
fail("Unsupported payment method")
)
Executes only if condition is true
const validateAdmin = when(
user => user.role === "admin",
checkAdminPrivileges
)
Executes only if condition is false
const validateGuest = unless(
user => user.isVerified,
requireVerification
)
Branch between two rules
const validateAge = ifElse(
user => user.age >= 18,
validateAdult,
validateMinor
)
Tries rules until one passes
const validateContact = oneOf(
validateEmail,
validatePhone,
validateUsername
)
Fallback when main rule fails
const validateWithFallback = withFallback(
primaryValidation,
backupValidation
)
Transforms error output
const friendlyErrors = mapError(
validatePassword,
err => `Security error: ${err}`
)
Inverts rule logic
const isNotBanned = not(
checkBanStatus,
"Account must be active"
)
Caches rule results
const cachedCheck = withMemoize(
dbUserLookup,
user => user.id,
{ ttl: 30000, maxSize: 100 }
)
Adds execution timeout
const timeboundRule = withTimeout(
apiCheck,
3000,
"Request timed out"
)
Automatic retries on failure
const resilientRule = withRetry(
flakyServiceCheck,
{ attempts: 3, delayMs: 1000 }
)
Enforces context requirements
const authRule = requireContextRule(
"Authentication required",
(input, ctx: AuthContext) => checkPermissions(ctx.token),
(ctx): ctx is AuthContext => !!ctx?.token
)
Lazy-loads context
const profileRule = withLazyContext(
userId => fetchProfile(userId),
validateProfile
)
Type guard for context
if (hasContext<AuthContext>(context)) {
// context is now typed as AuthContext
}
Adds debug logging
const debugRule = withDebug(validateOrder, {
name: "OrderValidation",
onEnd: (input, result) => console.log(result)
})
Performs side effects
const loggedRule = tap((input, result) => {
analytics.trackValidation(input, result)
})
Creates success result
const success = pass()
Creates failure result
const failure = fail("Invalid input")
Extracts error from failed result
if (result.status === "failed") {
const error = getRuleError(result)
}
Validates object fields
const validateEmail = validateField(
user => user.email,
validateEmailFormat
)
Dependency injection
const createDbRule = (db: Database) => (input: string) =>
db.exists(input) ? pass() : fail("Not found")
const dbRule = inject(database, createDbRule)
Function | Description | Example |
---|---|---|
pipeRules |
Sequential validation left-right (fail-fast) | pipeRules([checkA, checkB]) |
composeRules |
Sequential validation right-left (fail-fast) | composeRules([checkA, checkB]) |
every |
Parallel validation (collect all errors) | every([checkX, checkY]) |
match |
Pattern matching routing | match(getUserType, { admin: ruleA, user: ruleB }) |
ifElse |
Conditional routing | ifElse(isUnderAge, validateMinorAccount, validateAdultAccount) |
Combinator | Purpose | Example |
---|---|---|
when |
Conditional execution | when(isAdmin, validateAdmin) |
unless |
Negative condition | unless(isGuest, validateAccount) |
not |
Invert rule logic | not(isBanned, "Must not be banned") |
oneOf |
First-successful validation | oneOf(validateV1, validateV2) |
withFallback |
Fallback rule | withFallback(primary, backup) |
Utility | Use Case | Example |
---|---|---|
withMemoize |
Cache results | withMemoize(expensiveCheck, { ttl: 30000 }) |
withTimeout |
Add time limit | withTimeout(networkCall, 3000, "Timeout") |
withRetry |
Automatic retries | withRetry(unstableAPI, { attempts: 3 }) |
Tool | Purpose | Example |
---|---|---|
withDebug |
Debug logging | withDebug(rule, { name: "Validation" }) |
tap |
Side effects | tap((input, result) => log(result)) |
- Small Rules - Keep each rule focused
- Pure Functions - Avoid side effects in rules
- Memoize - Cache expensive validations
-
Type Narrowing - Use
requireContextRule
for safety - Instrument - Add metrics in production
graph TD
A[Input] --> B{Valid?}
B -->|Yes| C[Process]
B -->|No| D[Collect Errors]
C --> E[Output]
D --> E
- Fork the repository
- Create a feature branch (
git checkout -b feat/amazing-feature
) - Commit changes (
git commit -m 'Add amazing feature'
) - Push to branch (
git push origin feat/amazing-feature
) - Open a Pull Request
MIT © Breno Magalhães
Like this project? ⭐️ Star it on GitHub