Skip to content

01 · Document Approval (The Basics) ​

The smallest useful flowyd workflow. It introduces the four things every workflow needs — Zod-validated actions, states, the graph, and running an instance — and nothing else.

Workflow diagram ​

Features shown: createWorkflow, defineAction with Zod, addStep, setInitial / setTerminal / addTransition, dispatch, getCurrentStates / getAvailableTransitions / isTerminal, non-throwing blocked results, MermaidExporter.

Code ​

ts
import { z } from 'zod';
import { createWorkflow } from 'flowyd';
import { MermaidExporter } from 'flowyd/visualization';
ts
// ─── 1. Action payloads — every action is a Zod schema (the single source of truth) ───

const SubmitSchema = z.object({
  authorId: z.string(),
  title: z.string().min(1),
});

const ApproveSchema = z.object({
  editorId: z.string(),
  comment: z.string().optional(),
});

const RejectSchema = z.object({
  editorId: z.string(),
  reason: z.string().min(1),
});

const ResubmitSchema = z.object({
  authorId: z.string(),
});

// ─── 2 & 3. Build the workflow ─────────────────────────────────────────────────
//
// The builder is fluent and type-accumulating: each `addStep` widens the set of
// valid state IDs, so `setInitial`, `setTerminal`, and `addTransition` only
// accept IDs that actually exist. A typo here is a compile-time error.

export const approval = createWorkflow({ name: 'document-approval' })
  .defineAction('SUBMIT', SubmitSchema)
  .defineAction('APPROVE', ApproveSchema)
  .defineAction('REJECT', RejectSchema)
  .defineAction('RESUBMIT', ResubmitSchema)

  .addStep('draft', { label: 'Draft' })
  .addStep('in-review', { label: 'In Review' })
  .addStep('published', { label: 'Published' })
  .addStep('rejected', { label: 'Rejected' })

  .setInitial('draft')
  .setTerminal(['published'])

  .addTransition({ from: 'draft', to: 'in-review', on: 'SUBMIT' })
  .addTransition({ from: 'in-review', to: 'published', on: 'APPROVE' })
  .addTransition({ from: 'in-review', to: 'rejected', on: 'REJECT' })
  .addTransition({ from: 'rejected', to: 'in-review', on: 'RESUBMIT' })

  .build();

// ─── 4. Run an instance ─────────────────────────────────────────────────────────

async function run() {
  // Print the state diagram (paste into any Mermaid renderer).
  console.log('=== Document Approval — State Diagram ===\n');
  console.log(MermaidExporter.export(approval.getDefinition()));

  console.log('\n=== Run: a post that gets rejected once, then approved ===\n');

  // An instance is one independent run of the workflow. The id is yours.
  const post = approval.createInstance('post-2026-06-07');
  console.log(`start            → ${post.getCurrentStates()}`); // [draft]

  // `getAvailableTransitions` tells the UI which buttons to show right now.
  console.log(`can do from draft: ${post.getAvailableTransitions()}\n`); // [SUBMIT]

  await post.dispatch('SUBMIT', { authorId: 'alice', title: 'Typed workflows in TS' });
  console.log(`after SUBMIT     → ${post.getCurrentStates()}`); // [in-review]

  await post.dispatch('REJECT', { editorId: 'bob', reason: 'Needs a benchmark section' });
  console.log(`after REJECT     → ${post.getCurrentStates()}`); // [rejected]

  await post.dispatch('RESUBMIT', { authorId: 'alice' });
  console.log(`after RESUBMIT   → ${post.getCurrentStates()}`); // [in-review]

  const result = await post.dispatch('APPROVE', { editorId: 'bob', comment: 'Much better' });
  console.log(`after APPROVE    → ${post.getCurrentStates()}`); // [published]

  // `dispatch` returns a discriminated result — narrow on `result.success`.
  if (result.success) {
    console.log(`\nentered states this step : ${result.enteredStates}`);
    console.log(`terminal?                : ${post.isTerminal()}`);
    console.log(`version (steps taken)    : ${post.getSnapshot().version}`);
  }

  // ─── What "blocked" looks like ────────────────────────────────────────────────
  //
  // dispatch never throws for ordinary domain failures — it returns a typed
  // `{ success: false, reason }`. Your business logic handles it.

  console.log('\n=== Blocked dispatches (the expected, non-throwing kind) ===\n');

  // (a) The workflow is already terminal.
  const afterDone = await post.dispatch('SUBMIT', { authorId: 'alice', title: 'again?' });
  if (!afterDone.success) console.log(`SUBMIT on a published post → blocked: ${afterDone.reason}`);

  // (b) An action that's valid in the workflow, but not from the current state.
  const fresh = approval.createInstance('post-fresh');
  const tooEarly = await fresh.dispatch('APPROVE', { editorId: 'bob' });
  if (!tooEarly.success) console.log(`APPROVE while still draft   → blocked: ${tooEarly.reason}`);
}

What to notice ​

  • dispatch doesn't throw for ordinary domain failures. It returns a discriminated { success: false, reason } — here 'terminal-state' and 'no-active-source'. Narrow on result.success and let your business logic handle the rest. (Payload validation failure still throws a ZodError.)
  • State IDs are checked at compile time. A typo in any from/to/setInitial argument is a TypeScript error, because the set of valid IDs accumulates with each addStep.
  • getAvailableTransitions() is the no-guard list of actions valid from the current state — exactly what a UI needs to decide which buttons to render.

Run it locally: npx tsx examples/01-document-approval-basics.ts

Released under the MIT License.