Skip to main content

Core API

5 min read

Facts

Facts are your source of truth – reactive state that constraints, derivations, and effects observe.


Defining Facts

Define facts in your module schema using t type builders:

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

const userModule = createModule("user", {
  schema: {
    facts: {
      userId: t.number(),
      user: t.object<User>().nullable(),
      loading: t.boolean(),
      tags: t.array<string>().of(t.string()),
      status: t.enum("idle", "loading", "success", "error"),
    },
  },

  // Set initial values for all facts
  init: (facts) => {
    facts.userId = 0;
    facts.user = null;
    facts.loading = false;
    facts.tags = [];
    facts.status = "idle";
  },
});

Type Builders

The t object provides type builders for schema definitions. All builders return chainable types with dev-mode validation:

BuilderTypeScript TypeExample
t.string()stringt.string()
t.string<T>()T (literal union)t.string<"red" | "green">()
t.number()numbert.number()
t.boolean()booleant.boolean()
t.array<T>()T[]t.array<string>()
t.object<T>()Tt.object<User>()
t.enum(...)string literal uniont.enum("idle", "loading", "error")
t.literal(v)exact valuet.literal("admin"), t.literal(42)
t.union(...)union of typest.union(t.string(), t.number())
t.record(v)Record<string, V>t.record(t.number())

Common Modifiers

All type builders support these chainable methods:

// Nullability
t.string().nullable()          // string | null
t.number().optional()          // number | undefined

// Defaults
t.number().default(0)          // Default value used by init
t.array<string>().default([])  // Factory function also accepted: .default(() => [])

// Description (for devtools / introspection)
t.string().describe("The user's display name")

// Custom validation (dev-mode only)
t.string().validate(s => s.length > 0)
t.string().refine(s => s.includes("@"), "Must be an email")

// Transform values on set
t.string().transform(s => s.trim())

// Branded types (nominal typing)
t.string().brand<"UserId">()  // Branded<string, "UserId">

Number-Specific

t.number().min(0)             // Must be >= 0
t.number().max(100)           // Must be <= 100
t.number().min(0).max(100)    // Range

Array-Specific

t.array<string>().of(t.string())    // Validate each element
t.array<string>().nonEmpty()        // Must have at least 1 element
t.array<string>().minLength(2)      // Minimum length
t.array<string>().maxLength(50)     // Maximum length

Object-Specific

t.object<User>().shape({            // Validate specific properties
  name: t.string(),
  age: t.number(),
})
t.object<User>().nonNull()          // Must not be null or undefined
t.object<User>().hasKeys("id", "name")  // Must contain these keys

Reading Facts

Single Module

const system = createSystem({ module: userModule });

// Read facts as plain properties – fully typed
system.facts.userId;       // number
system.facts.user?.name;   // string | undefined
system.facts.loading;      // boolean

Multiple Modules (Namespaced)

const system = createSystem({
  modules: { auth: authModule, data: dataModule },
});

// Facts are namespaced by module name
system.facts.auth.token;   // Namespaced access
system.facts.data.items;

In Constraints

Constraints receive a scoped facts proxy:

constraints: {
  needsUser: {
    // Condition: have a userId but haven't fetched the user yet
    when: (facts) => facts.userId > 0 && !facts.user,
    require: { type: "FETCH_USER" },
  },
}

In Derivations

Derivations receive a scoped facts proxy with auto-tracking:

derive: {
  // Auto-tracks facts.user – recomputes when user changes
  displayName: (facts) => facts.user?.name ?? "Guest",
}

In Resolvers

Resolvers receive facts via context.facts:

resolvers: {
  fetchUser: {
    requirement: "FETCH_USER",
    resolve: async (req, context) => {
      // Read current facts
      const userId = context.facts.userId;

      // Write results back to facts
      context.facts.user = await api.getUser(userId);
      context.facts.loading = false;
    },
  },
}

Writing Facts

Assign to facts directly – each assignment triggers the reconciliation loop (constraints evaluate, derivations invalidate, effects run):

// Single update – triggers one reconciliation cycle
system.facts.userId = 123;

// Multiple updates – each triggers a separate reconciliation
system.facts.userId = 123;
system.facts.loading = true;

Batching Updates

Use batch to group updates into a single reconciliation:

// Group related updates into a single reconciliation cycle
system.batch(() => {
  system.facts.userId = 123;
  system.facts.loading = true;
  system.facts.status = "loading";
});
// All three changes are applied atomically

Replacing Arrays and Objects

Only top-level property assignment is tracked. Replace the entire value:

// Replace the entire array to trigger change detection
system.facts.tags = [...system.facts.tags, "new-tag"];

// Replace the entire object to trigger change detection
system.facts.user = { ...system.facts.user, name: "New Name" };

Deep mutations are NOT tracked

The facts proxy only intercepts top-level property set. Mutating nested properties or calling array methods in-place won't trigger updates:

// Won't trigger updates – the proxy doesn't see these
system.facts.user.name = "New";
system.facts.tags.push("new-tag");

// Do this instead – replace the whole value
system.facts.user = { ...system.facts.user, name: "New" };
system.facts.tags = [...system.facts.tags, "new-tag"];

Initial Values

The init function runs once when system.start() is called:

init: (facts) => {
  facts.userId = 0;
  facts.user = null;
  facts.loading = false;
  facts.tags = [];
},

You can also provide initial values when creating the system:

// Override init() defaults when creating the system
const system = createSystem({
  module: userModule,
  initialFacts: { userId: 42, loading: true },
});

// Namespaced overrides for multi-module systems
const system = createSystem({
  modules: { auth: authModule, data: dataModule },
  initialFacts: {
    auth: { token: "abc123" },
    data: { items: [] },
  },
});

initialFacts are applied after init() runs, overriding any values set by init.

Always initialize

Every fact in your schema should be initialized. Uninitialized facts will be undefined.


Hydration

For SSR or restoring persisted state, use hydrate() before start():

const system = createSystem({ module: userModule });

// Restore persisted state before starting (highest precedence)
await system.hydrate(async () => {
  const saved = await fetch('/api/state');

  return saved.json();
});

system.start();

Hydrated facts are applied after init() and initialFacts, taking highest precedence.


TypeScript Integration

Facts are fully typed based on your schema:

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

const system = createSystem({ module: userModule });

// TypeScript catches type errors at compile time
system.facts.userId = "123";  // Type error: string not assignable to number
system.facts.user?.name;      // string | undefined (correctly narrowed)
system.facts.nonExistent;     // Type error: property doesn't exist

Next Steps

Previous
Module & System

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