Home / Blog / Generate BPMN 2.0 diagrams programmatically in TypeScript

Generate BPMN 2.0 diagrams programmatically in TypeScript

Most BPMN diagrams are drawn by hand in a visual editor. That’s the right call when a human analyst is designing a process. It’s the wrong call when the process is generated by a template, assembled from configuration, or produced by an LLM — in those cases you want a fluent API that emits valid BPMN 2.0 XML, not a GUI to click through.

That’s what @bpmnkit/core is for.

A minimal process

import { Bpmn } from "@bpmnkit/core";

const xml = Bpmn.export(
  Bpmn.createProcess("my-flow")
    .startEvent("start")
    .serviceTask("task", {
      name: "Do Something",
      taskType: "my-worker", // Zeebe job type
    })
    .endEvent("end")
    .withAutoLayout()
    .build()
);

// ✓ Valid BPMN 2.0 XML
// ✓ Auto-layout applied
// ✓ Zeebe extensions set

Every call in the chain returns a new builder state, so the whole diagram is one type-checked expression. There’s no XML template string to get wrong, no namespace to remember, and no manual coordinate math — .withAutoLayout() runs a Sugiyama layout pass before export.

Branching with gateways

Real processes branch. @bpmnkit/core models exclusive and parallel gateways with a branch() callback that scopes each path:

const defs = Bpmn.createProcess("approval-flow")
  .startEvent("start", { name: "Request Submitted" })
  .userTask("review", { name: "Review Request" })
  .exclusiveGateway("gw", { name: "Approved?" })
  .branch("yes", (b) =>
    b.condition("= approved")
      .serviceTask("notify", { taskType: "send-email" })
      .endEvent("done")
  )
  .branch("no", (b) =>
    b.defaultFlow().endEvent("rejected")
  )
  .withAutoLayout()
  .build();

The cursor inside each branch() callback is scoped to that path — you don’t have to manually track “which element connects to what” the way you would when hand-writing XML.

Error paths with boundary events

Boundary events are one of the more awkward parts of BPMN to author by hand, because after attaching an error path you have to remember to move the cursor back to the main flow. withBoundary() does that for you:

const xml = Bpmn.export(
  Bpmn.createProcess("payment-flow")
    .startEvent("start")
    .serviceTask("charge", { name: "Charge Card", taskType: "payment-charge" })
    .withBoundary("on-fail", { errorCode: "PAYMENT_FAILED" }, (p) =>
      p.serviceTask("notify", { taskType: "send-email" }).endEvent("end-failed"),
    )
    // main flow continues from "charge" — not from the boundary
    .serviceTask("fulfill", { name: "Fulfill Order", taskType: "warehouse-pick" })
    .endEvent("end-ok")
    .withAutoLayout()
    .build()
);

Type guards and typed errors

Every BPMN element is a discriminated union member, so narrowing is compile-time safe:

import { isBpmnServiceTask, isBpmnGateway, ParseError } from "@bpmnkit/core";

for (const el of process.flowElements) {
  if (isBpmnServiceTask(el)) {
    console.log(el.taskDefinition.type); // fully typed, no cast
  }
}

Parsing malformed XML throws a typed ParseError (not a generic Error), so you can instanceof-catch it and handle it distinctly from a ValidationError.

Where to go next