Skip to main content

Core API

8 min read

Constraints

Constraints are the heart of Directive – they declare what must be true.


Basic Constraints

Define constraints in your module to declare conditions and their requirements:

import { createModule, t } from '@directive-run/core';

const userModule = createModule("user", {
  schema: {
    facts: {
      userId: t.number(),
      user: t.object<User>().nullable(),
      loading: t.boolean(),
    },
    requirements: {
      FETCH_USER: { userId: t.number() },
    },
  },

  constraints: {
    needsUser: {
      // When we have a userId but no user data and aren't loading
      when: (facts) => facts.userId > 0 && !facts.user && !facts.loading,

      // Dynamically build the requirement with the current userId
      require: (facts) => ({ type: "FETCH_USER", userId: facts.userId }),
    },
  },
});

Constraint Anatomy

PropertyTypeDescription
when(facts) => boolean | Promise<boolean>Condition – returns true when the constraint is active
requireRequirement | Requirement[] | (facts) => Requirement | Requirement[] | nullWhat to produce when when is true
prioritynumberEvaluation order (higher runs first, default: 0)
afterstring[]Constraint IDs that must resolve before this one evaluates
asyncbooleanMark as async (avoids runtime detection overhead)
timeoutnumberTimeout in ms for async when() evaluation (default: 5000)
depsstring[]Explicit fact dependencies (required for async constraints)

Cross-module access

To read facts from other modules, declare crossModuleDeps at the module level (not on individual constraints). This gives derive, constraints, and effects access via facts.self.* (own module) and facts.{dep}.* (cross-module). See Multi-Module for the full pattern.


Auto-Tracking

Constraint when() functions are auto-tracked – Directive records which facts are read during evaluation. On subsequent reconciliation cycles, only constraints affected by changed facts are re-evaluated. This means you don't need to declare dependencies manually.

constraints: {
  needsUser: {
    // Directive auto-tracks which facts are read here
    // Only re-evaluates when userId or user changes
    when: (facts) => facts.userId > 0 && facts.user === null,
    require: { type: "FETCH_USER" },
  },
}

Require Variants

The require field supports multiple forms:

constraints: {
  // Static – always produces the same requirement
  simple: {
    when: (facts) => !facts.data,
    require: { type: "FETCH_DATA" },
  },

  // Dynamic – builds requirement from current facts
  dynamic: {
    when: (facts) => facts.userId > 0 && !facts.user,
    require: (facts) => ({
      type: "FETCH_USER",
      userId: facts.userId,
      includeProfile: facts.needsProfile,
    }),
  },

  // Multiple – produce several requirements at once
  multiple: {
    when: (facts) => facts.isNewUser,
    require: [
      { type: "SEND_WELCOME_EMAIL" },
      { type: "CREATE_DEFAULT_SETTINGS" },
    ],
  },

  // Conditional – return null to skip producing a requirement
  conditional: {
    when: (facts) => facts.needsSync,
    require: (facts) => facts.isCritical
      ? [{ type: "SYNC_NOW" }, { type: "NOTIFY_ADMIN" }]
      : null,
  },
},

Priority Ordering

When multiple constraints are active, priority determines evaluation order:

constraints: {
  // Low priority – runs after higher-priority constraints
  lowPriority: {
    priority: 10,
    when: (facts) => facts.needsData,
    require: { type: "FETCH_DATA" },
  },

  // High priority – evaluated before lower numbers
  highPriority: {
    priority: 100,
    when: (facts) => facts.needsAuth,
    require: { type: "AUTHENTICATE" },
  },

  // Emergency – always evaluated first
  emergency: {
    priority: 1000,
    when: (facts) => facts.securityBreach,
    require: { type: "LOCKDOWN" },
  },
}

Higher priority constraints are evaluated first. Default priority is 0.

loadUser
checkAuth
validateCart
applyPromo
checkout

Constraint Dependencies (after)

loadConfig
authenticate
validateInput
processRequest

Use after to ensure one constraint's resolver completes before another constraint evaluates:

constraints: {
  // Step 1: Authenticate first
  authenticate: {
    when: (facts) => !facts.isAuthenticated,
    require: { type: "AUTH" },
  },

  // Step 2: Fetch user data after authentication completes
  fetchUserData: {
    after: ["authenticate"],
    when: (facts) => facts.isAuthenticated && !facts.userData,
    require: { type: "FETCH_USER_DATA" },
  },

  // Step 3: Fetch preferences after user data is loaded
  fetchPreferences: {
    after: ["fetchUserData"],
    when: (facts) => facts.userData && !facts.preferences,
    require: { type: "FETCH_PREFERENCES" },
  },
}

Behavior:

  • If constraint B has after: ["A"], B's when() is not called until A's resolver completes
  • If A's when() returns false (no requirement), B proceeds immediately – nothing to wait for
  • If A's resolver fails, B remains blocked until A succeeds (retries apply)
  • Cycles are detected at startup: "[Directive] Constraint cycle detected: A → B → A"

Priority vs after:

  • after always takes precedence – a constraint with after: ["A"] will always wait for A, regardless of priority
  • priority only affects ordering among constraints that have no after dependencies on each other
  • Constraints with the same priority and no mutual after dependencies may run in parallel

Cross-module references: Use "moduleName::constraintName" format for after dependencies across modules. Note: unlike deps, constraint after references are not auto-prefixed in multi-module systems – you must use the full "namespace::constraintName" format.


Async Constraints

The when() function can be async for conditions that require I/O. Mark with async: true to avoid runtime detection overhead:

constraints: {
  needsPermission: {
    async: true,
    timeout: 3000,  // Override default 5s timeout
    when: async (facts) => {
      // Check external permission service before proceeding
      const allowed = await checkPermissions(facts.userId);

      return allowed && !facts.hasData;
    },
    require: { type: "FETCH_DATA" },
  },
}

If you omit async: true and when() returns a Promise, Directive detects it at runtime and logs a dev warning. Async constraints within the same evaluation cycle run in parallel.

Async race conditions

When when() is async, facts can change while the promise is pending. Any fact reads after await see the latest values, not the values at evaluation start. For stable behavior, read all facts before the first await, or use explicit deps to declare which facts the constraint depends on:

constraints: {
  asyncSafe: {
    async: true,
    deps: ["userId", "hasData"],  // Explicit deps – re-evaluated when these change
    when: async (facts) => {
      const allowed = await checkPermissions(facts.userId);

      return allowed && !facts.hasData;
    },
    require: { type: "FETCH_DATA" },
  },
}

Namespace syntax in multi-module systems

Multi-module systems use different separators for different contexts:

  • Constraint after: "moduleName::constraintName" (double colon)
  • Fact access in code: system.facts.moduleName.factKey (dot access)
  • Constraint deps: ["factKey"] (auto-prefixed with module namespace)

The :: separator is used internally and in after references. You never need it for fact access or deps – those are handled automatically.


Complex Conditions

Combine multiple conditions for precise control:

constraints: {
  canCheckout: {
    when: (facts) => {
      // All conditions must be met before checkout can proceed
      const hasItems = facts.cart.items.length > 0;
      const hasPayment = facts.paymentMethod !== null;
      const isAuthenticated = facts.user !== null;
      const notProcessing = !facts.checkoutInProgress;

      return hasItems && hasPayment && isAuthenticated && notProcessing;
    },
    require: { type: "PROCESS_CHECKOUT" },
  },
}

Constraint Groups

Organize related constraints logically:

const cartModule = createModule("cart", {
  constraints: {
    // --- Validation constraints (highest priority) ---
    validateStock: {
      priority: 100,
      when: (facts) => facts.needsStockCheck,
      require: { type: "CHECK_STOCK" },
    },
    validatePricing: {
      priority: 100,
      when: (facts) => facts.needsPriceCheck,
      require: { type: "CHECK_PRICES" },
    },

    // --- Action constraints (medium priority) ---
    applyDiscount: {
      priority: 50,
      when: (facts) => facts.discountCode && !facts.discountApplied,
      require: { type: "APPLY_DISCOUNT" },
    },
    calculateTax: {
      priority: 50,
      when: (facts) => facts.subtotal > 0 && !facts.taxCalculated,
      require: { type: "CALCULATE_TAX" },
    },

    // --- Final constraints (run after validations complete) ---
    checkout: {
      priority: 10,
      after: ["validateStock", "validatePricing", "calculateTax"],
      when: (facts) => facts.readyToCheckout,
      require: { type: "CHECKOUT" },
    },
  },
});

Constraint Evaluation

Constraints are evaluated:

  1. When facts change (only affected constraints, thanks to auto-tracking)
  2. After a resolver completes
  3. During reconciliation (triggered by system.start() and fact changes)

The engine continuously evaluates until no more constraints are active.


Preventing Re-Triggering

Guard against infinite loops by checking for existing data:

constraints: {
  fetchUser: {
    when: (facts) => {
      // Guard against re-triggering: only fetch if we don't have
      // the user AND aren't already loading
      return facts.userId > 0
        && facts.user === null
        && !facts.loading;
    },
    require: { type: "FETCH_USER" },
  },
}

Best Practices

Keep Conditions Pure

// Good - pure function
when: (facts) => facts.count > 10

// Bad - side effects in condition
when: (facts) => {
  console.log("Checking...");  // Don't do this

  return facts.count > 10;
}

Use Descriptive Names

constraints: {
  // Good - describes intent
  userNeedsAuthentication: { ... },
  cartRequiresPriceRecalculation: { ... },

  // Bad - vague names
  check1: { ... },
  doThing: { ... },
}

Single Responsibility

Each constraint should handle one specific requirement:

// Good - separate concerns
constraints: {
  needsAuth: { when: ... , require: { type: "AUTH" } },
  needsProfile: { when: ..., require: { type: "FETCH_PROFILE" } },
}

// Bad - mixed concerns
constraints: {
  setup: { when: ..., require: { type: "AUTH_AND_FETCH_PROFILE" } },
}

Runtime Control & Registration

Constraints support enable/disable toggling and dynamic registration:

system.constraints.disable("expensiveCheck");
system.constraints.enable("expensiveCheck");
system.constraints.register("override", { when: ..., require: ... });
system.constraints.assign("existing", { when: ..., require: ... });
system.constraints.unregister("override");

All four subsystems (constraints, effects, resolvers, derivations) share the same registration interface. See Runtime Dynamics for the full semantics table, introspection methods, and use cases.


Definition Meta

Attach optional metadata for better debugging and devtools:

constraints: {
  needsUser: {
    when: (facts) => !facts.user,
    require: { type: "FETCH_USER" },
    meta: { label: "Requires User", category: "data" },
  },
},

Meta surfaces in system.inspect(), system.explain(), and the devtools plugin. See Definition Meta for the full API.


Next Steps

Previous
Derivations

Stay in the loop. Sign up for our newsletter.

We care about your data. We'll never share your email.

Powered by Directive. This signup uses a Directive module with facts, derivations, constraints, and resolvers – zero useState, zero useEffect. Read how it works

Directive - Constraint-Driven Runtime for TypeScript | AI Guardrails & State Management