DEV Community

Tejas Kadam
Tejas Kadam

Posted on

Why I stopped hardcoding conditional logic in enterprise forms (and open-sourced the fix)

Every enterprise app I've worked on eventually hits the same wall: a form or workflow that needs conditional logic — "show field B if A is High," "require C unless D is Approved," "disable E for 30 days after F." The first version is always a few *ngIf*s. Six months later it's an unmaintainable tree of nested conditionals that only the original author fully understands.

I've rebuilt the fix for this three times now, most recently as the rule engine behind a compliance platform's dynamic audit checklists. This time I pulled the core idea out into a standalone package — rule-lite (https://github.com/tejas821/rule-lite) — because the problem is common enough that it shouldn't need reinventing per project.

The core idea

Rules are data, not code:

{
  "all": [
    { "field": "user.age", "operator": "gte", "value": 18 },
    {
      "any": [
        { "field": "user.role", "operator": "eq", "value": "admin" },
        { "field": "user.country", "operator": "eq", "value": "IN" }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode
import { RuleEngine } from 'rule-lite';

const engine = new RuleEngine();
engine.evaluate(rule, { user: { age: 25, role: 'guest', country: 'IN' } }); // true
Enter fullscreen mode Exit fullscreen mode

That's the whole API surface: AND/OR/NOT compose to unlimited depth, a dozen built-in operators (eq, gt, in, contains, exists...), and a registerOperator hook for anything domain-specific. Zero runtime dependencies, framework-agnostic — same rule JSON works from an Angular service, a Node backend doing entitlement checks, or a CLI validation script.

Why object-shaped conditions, not a compact DSL

I considered three shapes before settling on this one:

A string DSL ("age >= 18 && country == 'IN'") is the most compact to author, but needs a parser, isn't safely JSON-serializable without escaping, and is hard to validate before evaluation.

A JsonLogic-style array DSL ({"and": [{">=": [{"var": "age"}, 18]}]}) is what JsonLogic itself does — very compact, but genuinely unreadable without the spec open next to it.

Object-shaped conditions ({ field, operator, value }) — what shipped — are more verbose JSON, but self-documenting and trivial to build a form-builder UI around: a select for field, a select for operator, an input for value. That's the actual use case I care about — dynamic forms and business rules that a product or config team, not just engineers, eventually needs to author.

Readability and "can someone configure this without reading a spec" won over raw compactness.

What's next

I'm using this as the first entry in a small "-lite" family of enterprise-focused utility packages — next up is an RBAC/ABAC permission checker that uses rule-lite internally for attribute-based conditions.

If you build dynamic forms, feature flags, or business-rule systems in TypeScript and this shape of problem sounds familiar, I'd genuinely like feedback on the API — especially the operator set and whether the all/any/not composition covers real cases you've hit.

Top comments (3)

Collapse
 
alexshev profile image
Alex Shev

Enterprise forms become hard when rules live in too many places: UI conditions, backend validation, permissions, and workflow state. Moving conditions into a clear model helps, but the real test is whether non-obvious edge cases become easier to review.

Collapse
 
tejas821 profile image
Tejas Kadam

Really fair point — moving conditions into a model doesn't fix the "rules live in too many places" problem by itself, it just changes where they live. What I was actually chasing with the object shape was portability: the same rule JSON can run against a form validator on the frontend and a permission/API guard on the backend, so the condition itself isn't reimplemented per layer. On the "real test" point — that's exactly why I leaned verbose over compact. Since a rule is data, edge cases become table-driven tests over the JSON itself (an array of {rule, context, expected} fixtures run through evaluate()) instead of assertions buried wherever the logic happens to live. Doesn't solve the review problem you're describing, but it turns "did we cover this edge case" into something diffable and greppable rather than tribal knowledge.

Appreciate the pushback, this is the kind of feedback I was hoping for.

Collapse
 
alexshev profile image
Alex Shev

That portability goal is the part that makes the model worthwhile. If the same rule object can drive frontend visibility, backend validation, and permission checks, then the review surface becomes the rule data itself. Table-driven fixtures are a good way to make that less magical.