Skip to content

07 · Workflows Defined at Runtime ​

Every other example hard-codes its states, so flowyd infers a precise union of state-ID literals and catches typos at compile time. When the shape of a workflow isn't known until runtime — it comes from a database row, a JSON config, or a no-code builder — use createDynamicWorkflow: TStates is pre-widened to string, so you can build in loops. You trade compile-time ID checking for runtime validation, and build() still enforces every structural and graph invariant.

Workflow diagram ​

Even a runtime-defined workflow has an inspectable graph. This is the employee-onboarding workflow compiled from the config below — its diagram is generated from the compiled definition, exactly like the static examples.

Features shown: createDynamicWorkflow, compiling a plain-data config into a runnable workflow, build() rejecting a malformed graph, and generating an N-stage chain purely from an array.

Code ​

ts
import { z } from 'zod';
import { createDynamicWorkflow } from 'flowyd';
import { MermaidExporter } from 'flowyd/visualization';
ts
// ─── The config shape — what a database / JSON file / builder UI would emit ──────

interface WorkflowConfig {
  name: string;
  actions: string[];
  stages: { id: string; label: string }[];
  initial: string;
  terminal: string[];
  arcs: { from: string; to: string; on: string }[];
}

// On a dynamic workflow `TActions` is `Record<string, unknown>`, so dispatch
// payloads are `unknown` and the only statically-safe payload is `{}`. That fits
// config/no-code engines, which key off state (and context) rather than typed
// action payloads. A richer per-action schema is possible — you'd just validate
// and narrow at the dispatch boundary.
const ActionSchema = z.object({});

/**
 * Compile a runtime config into an executable workflow. Note that nothing here
 * knows the concrete state IDs — they're just strings flowing from the config.
 */
function compile(config: WorkflowConfig) {
  const builder = createDynamicWorkflow({ name: config.name });

  for (const action of config.actions) {
    builder.defineAction(action, ActionSchema);
  }
  for (const stage of config.stages) {
    builder.addStep(stage.id, { label: stage.label });
  }
  builder.setInitial(config.initial).setTerminal(config.terminal);
  for (const arc of config.arcs) {
    builder.addTransition({ from: arc.from, to: arc.to, on: arc.on });
  }

  // build() validates everything at runtime: unknown IDs, unreachable states,
  // unterminating graphs, missing action schemas, etc.
  return builder.build();
}

// ─── 1 & 2. A config loaded "from the database", compiled and run ───────────────

const onboardingConfig: WorkflowConfig = {
  name: 'employee-onboarding',
  actions: ['SEND_OFFER', 'ACCEPT', 'PROVISION', 'COMPLETE'],
  stages: [
    { id: 'candidate', label: 'Candidate' },
    { id: 'offer-sent', label: 'Offer Sent' },
    { id: 'accepted', label: 'Accepted' },
    { id: 'provisioning', label: 'Provisioning' },
    { id: 'onboarded', label: 'Onboarded' },
  ],
  initial: 'candidate',
  terminal: ['onboarded'],
  arcs: [
    { from: 'candidate', to: 'offer-sent', on: 'SEND_OFFER' },
    { from: 'offer-sent', to: 'accepted', on: 'ACCEPT' },
    { from: 'accepted', to: 'provisioning', on: 'PROVISION' },
    { from: 'provisioning', to: 'onboarded', on: 'COMPLETE' },
  ],
};

// The workflow compiled from the config above — exported so the docs can render
// its diagram (dynamic workflows have no compile-time state IDs, but the
// compiled graph is just as inspectable as any other).
export const onboardingWorkflow = compile(onboardingConfig);

async function runConfigDriven() {
  console.log('=== 1. Compiled from a runtime config ===\n');
  const onboarding = compile(onboardingConfig);
  console.log(MermaidExporter.export(onboarding.getDefinition()));

  console.log('\n  Running it:');
  const hire = onboarding.createInstance('hire-7788');
  for (const action of onboardingConfig.actions) {
    const result = await hire.dispatch(action, {});
    if (result.success) console.log(`    ${action.padEnd(12)} → ${hire.getCurrentStates()}`);
  }
  console.log(`    terminal: ${hire.isTerminal()}`);
}

// ─── 3. A broken config is caught by build(), not at dispatch time ──────────────

function runValidation() {
  console.log('\n=== 2. build() rejects a malformed config ===\n');
  const broken: WorkflowConfig = {
    name: 'broken',
    actions: ['GO'],
    stages: [
      { id: 'start', label: 'Start' },
      { id: 'end', label: 'End' },
      { id: 'orphan', label: 'Orphan' }, // nothing points here
    ],
    initial: 'start',
    terminal: ['end'],
    arcs: [{ from: 'start', to: 'end', on: 'GO' }],
  };

  try {
    compile(broken);
    console.log('  (unexpected: build succeeded)');
  } catch (err) {
    console.log(`  build() threw, as it should:\n    ${(err as Error).message.split('\n').join('\n    ')}`);
  }
}

// ─── 4. The workflow's SIZE is data: an N-level approval chain ───────────────────

/** Build a linear approval chain with one stage per approver level. */
function buildApprovalChain(levels: string[]): WorkflowConfig {
  const stages = [
    { id: 'submitted', label: 'Submitted' },
    ...levels.map((lvl) => ({ id: `approve-${lvl}`, label: `Awaiting ${lvl}` })),
    { id: 'approved', label: 'Approved' },
    { id: 'rejected', label: 'Rejected' },
  ];

  // Chain: submitted → approve-L0 → approve-L1 → ... → approved.
  const chainIds = ['submitted', ...levels.map((l) => `approve-${l}`), 'approved'];
  const arcs: WorkflowConfig['arcs'] = [];
  for (let i = 0; i < chainIds.length - 1; i++) {
    arcs.push({ from: chainIds[i]!, to: chainIds[i + 1]!, on: 'APPROVE' });
  }
  // Any pending approval level can also reject.
  for (const lvl of levels) {
    arcs.push({ from: `approve-${lvl}`, to: 'rejected', on: 'REJECT' });
  }

  return {
    name: `approval-chain-${levels.length}`,
    actions: ['APPROVE', 'REJECT'],
    stages,
    initial: 'submitted',
    terminal: ['approved', 'rejected'],
    arcs,
  };
}

async function runGeneratedChain() {
  console.log('\n=== 3. A 4-level approval chain generated from an array ===\n');
  const levels = ['team-lead', 'manager', 'director', 'vp'];
  const chain = compile(buildApprovalChain(levels));
  console.log(`  generated ${chain.getDefinition().states.size} states from ${levels.length} levels\n`);

  const request = chain.createInstance('req-2026-42');
  console.log(`  start → ${request.getCurrentStates()}`);
  // Approve all the way up the chain.
  for (let i = 0; i <= levels.length; i++) {
    const r = await request.dispatch('APPROVE', {});
    if (r.success) console.log(`  APPROVE → ${request.getCurrentStates()}`);
  }
  console.log(`  terminal: ${request.isTerminal()}`);
}

async function main() {
  await runConfigDriven();
  runValidation();
  await runGeneratedChain();
}

What to notice ​

  • Validation moves from the compiler to build(). Unknown IDs, unreachable states, non-terminating graphs, and missing action schemas all throw at build time — before any instance runs — as the malformed config demonstrates.
  • Payloads are unknown on a dynamic workflow, so the only statically-safe payload is {}. That fits config/no-code engines, which key off state (and context) rather than typed action payloads; a richer per-action schema is still possible if you validate and narrow at the dispatch boundary.
  • The workflow's size is data. The N-level approval chain is generated from an array of approver levels — add a level to the array, not to the code.

Run it locally: npx tsx examples/07-dynamic-workflow.ts

Released under the MIT License.