Skip to main content

Core API

7 min read

Builders

Fluent builder APIs for creating constraints, modules, and systems. Builders provide an ergonomic alternative to object literals with full TypeScript inference.


Constraint Builders

Two ways to build typed constraints outside of createModule().

constraint() – Full Builder

Chain .when(), .require(), optional fields, then .build(). All fields from TypedConstraintDef are supported.

import { constraint } from '@directive-run/core';

const escalate = constraint<typeof schema>()
  .when(f => f.confidence < 0.7)
  .require({ type: 'ESCALATE' })
  .priority(50)
  .after('healthCheck')
  .deps('confidence')
  .timeout(5000)
  .async(true)
  .build();

The chain enforces order: .when() first, .require() second, then any optional methods, then .build().

MethodRequiredDescription
.when(fn)YesCondition function – receives typed facts
.require(value)YesRequirement(s), function, array, or null
.priority(n)NoHigher runs first
.after(...ids)NoWait for other constraints' resolvers
.deps(...keys)NoExplicit fact dependencies (required for async)
.timeout(ms)NoTimeout for async evaluation
.async(bool)NoMark as async constraint
.build()YesReturns TypedConstraintDef<M>

when() – Quick Shorthand

Returns a valid constraint directly – no .build() needed. Optional chaining via with* methods returns a new immutable constraint each time.

import { when } from '@directive-run/core';

// Minimal – ready to use immediately
const pause = when<typeof schema>(f => f.errors > 3)
  .require({ type: 'PAUSE' });

// With options (immutable – each call returns a new constraint)
const halt = when<typeof schema>(f => f.errors > 10)
  .require({ type: 'HALT' })
  .withPriority(100)
  .withAfter('healthCheck');
MethodDescription
.require(value)Required – returns the constraint
.withPriority(n)Returns new constraint with priority
.withAfter(...ids)Returns new constraint with after deps
.withDeps(...keys)Returns new constraint with explicit deps
.withTimeout(ms)Returns new constraint with timeout
.withAsync(bool)Returns new constraint marked async

require Accepts Multiple Forms

Both builders accept the same require values:

// Static requirement
.require({ type: 'PAUSE' })

// Dynamic (function)
.require(f => ({ type: 'TRANSITION', to: f.phase === 'red' ? 'green' : 'red' }))

// Multiple requirements
.require([{ type: 'PAUSE' }, { type: 'ESCALATE' }])

// Suppress (no requirement even when condition matches)
.require(null)

Using Builder Output in Modules

Builder output is a plain TypedConstraintDef<M> – drop it directly into constraints:

const myConstraint = when<typeof schema>(f => f.errors > 3)
  .require({ type: 'PAUSE' })
  .withPriority(50);

const myModule = createModule('example', {
  schema,
  constraints: {
    pause: myConstraint,    // Works directly
    escalate: constraint<typeof schema>()
      .when(f => f.confidence < 0.5)
      .require({ type: 'ESCALATE' })
      .build(),             // Also works
  },
  // ...
});

Module Builder

The module() builder provides a fluent alternative to createModule().

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

const counter = module('counter')
  .schema({
    facts: { count: t.number(), name: t.string() },
    derivations: { doubled: t.number() },
    events: { increment: {}, decrement: {} },
    requirements: {},
  })
  .init(facts => {
    facts.count = 0;
    facts.name = 'counter';
  })
  .derive({
    doubled: facts => facts.count * 2,
  })
  .events({
    increment: facts => { facts.count++; },
    decrement: facts => { facts.count--; },
  })
  .build();

All methods are optional except .schema() and .build(). The builder validates that all declared derivations and events have implementations.


System Builder

The system() builder provides a fluent alternative to createSystem().

Single Module

import { system } from '@directive-run/core';

const sys = system()
  .module(counterModule)
  .plugins([loggingPlugin()])
  .debug({ timeTravel: true })
  .initialFacts({ count: 10 })
  .build();

sys.start();

Multiple Modules (Namespaced)

const sys = system()
  .modules({ auth: authModule, cart: cartModule })
  .plugins([loggingPlugin()])
  .errorBoundary({ onResolverError: 'retry' })
  .initOrder('auto')
  .build();

sys.start();

Calling .module() or .modules() narrows the builder type – you can't mix them.

MethodSingleNamespacedDescription
.module(mod)YesSingle module, direct access
.modules({ ... })YesObject of modules, namespaced access
.plugins([...])YesYesRegister plugins
.debug({...})YesYesDebug/time-travel config
.errorBoundary({...})YesYesError recovery strategies
.tickMs(n)YesYesTick interval (ms)
.zeroConfig()YesYesSensible defaults for dev
.initialFacts({...})YesYesFacts to set after init
.initOrder(order)YesModule initialization order
.build()YesYesCreates the system

Complete Examples

Module with Constraint Builders

A full module definition using when() and constraint() for reusable, composable constraints.

import { createModule, constraint, when, t } from '@directive-run/core';
import type { ModuleSchema } from '@directive-run/core';

const schema = {
  facts: {
    items: t.array<string>(),
    status: t.string<'idle' | 'loading' | 'error'>(),
    errorCount: t.number(),
    lastFetch: t.number(),
  },
  derivations: {
    isEmpty: t.boolean(),
    shouldRetry: t.boolean(),
  },
  events: {
    addItem: { item: t.string() },
    clearErrors: {},
  },
  requirements: {
    FETCH_ITEMS: {},
    PAUSE: {},
    ALERT: { message: t.string() },
  },
} satisfies ModuleSchema;

// Reusable constraints defined outside the module
const fetchWhenEmpty = when<typeof schema>(f => f.items.length === 0 && f.status === 'idle')
  .require({ type: 'FETCH_ITEMS' });

const pauseOnErrors = when<typeof schema>(f => f.errorCount > 3)
  .require({ type: 'PAUSE' })
  .withPriority(90);

const alertOnCritical = constraint<typeof schema>()
  .when(f => f.errorCount > 10)
  .require(f => ({ type: 'ALERT', message: `${f.errorCount} errors detected` }))
  .priority(100)
  .after('pauseOnErrors')
  .deps('errorCount')
  .build();

const itemsModule = createModule('items', {
  schema,
  init: (facts) => {
    facts.items = [];
    facts.status = 'idle';
    facts.errorCount = 0;
    facts.lastFetch = 0;
  },
  derive: {
    isEmpty: (facts) => facts.items.length === 0,
    shouldRetry: (facts) => facts.status === 'error' && facts.errorCount <= 3,
  },
  events: {
    addItem: (facts, { item }) => { facts.items = [...facts.items, item]; },
    clearErrors: (facts) => { facts.errorCount = 0; facts.status = 'idle'; },
  },
  // Mix builder-created and inline constraints
  constraints: {
    fetchWhenEmpty,
    pauseOnErrors,
    alertOnCritical,
    // Inline constraint (object literal) works alongside builders
    staleData: {
      when: (facts) => Date.now() - facts.lastFetch > 60_000,
      require: { type: 'FETCH_ITEMS' },
      priority: 10,
    },
  },
  resolvers: {
    fetchItems: {
      requirement: 'FETCH_ITEMS',
      retry: { attempts: 3, backoff: 'exponential', initialDelay: 500 },
      resolve: async (_req, context) => {
        context.facts.status = 'loading';
        // ... fetch logic
        context.facts.lastFetch = Date.now();
        context.facts.status = 'idle';
      },
    },
  },
});

Full App with System Builder

Wire up multiple modules, plugins, and configuration using the system() builder.

import { system, module, when, t } from '@directive-run/core';
import { loggingPlugin } from '@directive-run/core/plugins';
import type { ModuleSchema } from '@directive-run/core';

// Auth module (using module builder)
const authModule = module('auth')
  .schema({
    facts: { token: t.string(), role: t.string<'guest' | 'user' | 'admin'>() },
    derivations: { isAuthenticated: t.boolean() },
    events: {
      login: { token: t.string(), role: t.string() },
      logout: {},
    },
    requirements: {},
  } satisfies ModuleSchema)
  .init(facts => {
    facts.token = '';
    facts.role = 'guest';
  })
  .derive({
    isAuthenticated: (facts) => facts.token !== '',
  })
  .events({
    login: (facts, { token, role }) => {
      facts.token = token;
      facts.role = role as 'guest' | 'user' | 'admin';
    },
    logout: (facts) => {
      facts.token = '';
      facts.role = 'guest';
    },
  })
  .build();

// Data module (using createModule + constraint builders)
const dataSchema = {
  facts: {
    users: t.array<{ id: string; name: string }>(),
    loaded: t.boolean(),
  },
  derivations: { userCount: t.number() },
  events: {},
  requirements: { LOAD_USERS: {} },
} satisfies ModuleSchema;

const loadWhenNeeded = when<typeof dataSchema>(f => !f.loaded)
  .require({ type: 'LOAD_USERS' });

const dataModule = createModule('data', {
  schema: dataSchema,
  init: (facts) => { facts.users = []; facts.loaded = false; },
  derive: { userCount: (facts) => facts.users.length },
  constraints: { loadWhenNeeded },
  resolvers: {
    loadUsers: {
      requirement: 'LOAD_USERS',
      resolve: async (_req, context) => {
        const res = await fetch('/api/users');
        context.facts.users = await res.json();
        context.facts.loaded = true;
      },
    },
  },
});

// System builder wires everything together
const app = system()
  .modules({ auth: authModule, data: dataModule })
  .plugins([loggingPlugin()])
  .debug({ timeTravel: true, maxSnapshots: 50 })
  .errorBoundary({ onResolverError: 'retry' })
  .zeroConfig()
  .initialFacts({
    auth: { token: 'restored-token', role: 'user' },
  })
  .build();

app.start();

// Namespaced access
app.facts.auth.token;           // 'restored-token'
app.derive.data.userCount;      // 0 (until resolver completes)
app.events.auth.logout();       // dispatch logout event

When to Use Builders vs Object Literals

ScenarioRecommended
Inline constraints in createModule()Object literals
Reusable constraints shared across modulesconstraint() or when()
Quick one-off constraintwhen() shorthand
Constraint with many optional fieldsconstraint() full builder
Simple system setupcreateSystem()
System with many optionssystem() builder

Both approaches produce identical runtime output – builders are syntax sugar with type inference.


Next Steps

Previous
Events

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 State Management for TypeScript