skip to content
docs / building blocks

chain()

Sequence nodes, each output feeding the next input. Chains are nodes, so they nest.

Sequencing#

chain(id, ...nodes) runs nodes one after another, feeding each one's output into the next one's input. The chain's input is the first node's input; its output is the last node's output. That's the whole contract.

moderate.ts
import { chain, step, ask, noul } from "jevchain";

const pipeline = chain(
  "moderate",
  step("clean", (s: string) => s.trim()),                  // string → string
  ask("read", { questions: { rude: noul("Is this rude?") } }), // string → { rude: NoulAnswer }
  step("decide", (a) => (a.rude.noul > 0.7 ? "hide" : "show")), // → "hide" | "show"
);
// ChainNode<string, "hide" | "show">

Remember the mental model: route and gate pass their input through untouched, so a chain step after a route receives whatever the taken branch produced.

Types across the links#

Every node carries phantom input and output types, and chain's overloads line them up: chain(a, b) only compiles when a's output fits b's input. Get it wrong and you find out in your editor, not in production at 3am:

oops.ts
✗ tsc
const c = chain(
  "c",
  step("n", () => 1),
  step("s", (x: string) => x),
);
error TS2345: Argument of type 'StepNode<unknown, number>' is not assignable to parameter of type 'JevNode<unknown, string>'.
  Types of property '[io]' are incompatible.
    …
        Types of property 'out' are incompatible.
          Type 'number' is not assignable to type 'string'.

The message is long but the last line is the one that matters. (The [io] property is the phantom field that carries the types. It's never set at runtime.)

To name a node's types, use InputOf and OutputOf. OutputOf is the one you'll reach for most: it types a step's parameter from the node before it, so the step follows along when you add a question.

types.ts
import type { InputOf, OutputOf } from "jevchain";

type In = InputOf<typeof pipeline>;   // string
type Out = OutputOf<typeof pipeline>; // "hide" | "show"

// Type a step's parameter from whatever feeds it, so it follows along when that changes.
const read = ask("read", { questions: { rude: noul("Is this rude?") } });
const decide = step("decide", (a: OutputOf<typeof read>) => a.rude.noul > 0.7);

Nesting#

A chain is a node, so chains go anywhere nodes go: inside other chains, as route branches, as gate paths, as parallel branches, as a cascade fallback. Nesting is free. A chain has no vertex of its own in the graph; its steps are just laid end to end, and in the trace it's a span whose children are its steps (paths like $/moderate/0, $/moderate/1).

desk.ts
// Small chains, named for what they do…
const understand = chain("understand", clean, read);
const respond = chain("respond", decide, format);

// …composed into bigger ones. Same rules, same types.
export const moderate = chain("moderate", understand, respond);

// A chain can be a branch, too.
const desk = route("desk", {
  ask: choice("Which queue?", ["moderation", "support"]),
  branches: { moderation: moderate, support: supportFlow },
});

Want a friendlier label in the studio and the graph? describe(node, { title, description }) returns a copy with UI metadata attached.

desk.ts
import { describe } from "jevchain";

// A copy with a title/description for UIs; the original is untouched.
const understand = describe(chain("understand", clean, read), {
  title: "Understand the message",
  description: "Normalize, then ask Jev what it is.",
});
▶ run itExcuse evaluatordocs chainchains.ts ↗
A cleanup step, then a nested evaluate chain: one ask with two questions and a step typed with OutputOf. Try blaming Dave.
chains.ts
const rateExcuse = ask("rate-excuse", {
  questions: {
    plausible: score("How plausible is this excuse for missing standup?", [
      "my dog ate my laptop",
      "suspicious",
      "plausible",
      "airtight",
    ]),
    blamesSomeone: noul("Does the excuse blame a coworker?"),
  },
});

// OutputOf pulls a node's output type out, so the next step is fully typed.
type Rating = OutputOf<typeof rateExcuse>;

const verdict = step("verdict", (r: Rating) =>
  r.blamesSomeone.noul > 0.5
    ? "Excuse rejected. We don't throw Dave under the bus."
    : r.plausible.score >= 2
      ? "Excused. See you tomorrow."
      : "Noted. Your camera will be on next time.",
);

// A chain is a node, so chains nest inside chains.
const evaluate = chain("evaluate", rateExcuse, verdict);
const excuses = chain(
  "excuse-evaluator",
  step("clean", (s: string) => s.trim()),
  evaluate,
);
step · cleanstepcleanask · rate-excuseaskrate-excusestep · verdictstepverdict

Sorry, I had a dentist appointment I forgot to put in the calendar.

open in studio →

Seven links, then nest#

The typed overloads cover one to seven nodes. That's not a runtime limit; it's where the type signatures stop. Past seven you'll get an overload error, and the fix is the one you'd want anyway:

  • Group related links into named sub-chains. Seven anonymous steps in a row is a code smell with a trace.
  • Named sub-chains show up as their own spans, so “which part was slow?” is answered by looking, not by adding logs.
  • They're reusable: the same sub-chain can sit in two routes. Ids needn't be unique; paths are.

api key

bring your own typesafe key, or ride the shared one (rate-limited, be nice).

shared key
checking…
your key
not set

your key stays in this browser (localStorage, jevchain.byok). it only travels to this site's /api/jev proxy in an x-typesafe-key header, which forwards it to typesafe and immediately forgets it. nothing is logged or stored server-side. requests on your own key get a much roomier rate limit.

keyboard shortcuts

fewer clicks, more chains. these work anywhere outside a text field.