1"use strict";(globalThis.webpackChunkbarnum_docs=globalThis.webpackChunkbarnum_docs||[]).push([[5827],{2948(e,n,t){t.r(n),t.d(n,{assets:()=>o,contentTitle:()=>l,default:()=>h,frontMatter:()=>r,metadata:()=>s,toc:()=>d});var i=t(6106),a=t(9236);const r={},l="Best Practices",s={id:"reference/best-practices",title:"Best Practices",description:"Three principles drive every decision below:",source:"@site/versioned_docs/version-0.4/reference/best-practices.md",sourceDirName:"reference",slug:"/reference/best-practices",permalink:"/docs/reference/best-practices",draft:!1,unlisted:!1,tags:[],version:"0.4",frontMatter:{},sidebar:"documentationSidebar",previous:{title:"Localization",permalink:"/docs/repertoire/localization"},next:{title:"Handlers",permalink:"/docs/reference/handlers"}},o={},d=[{value:"Handler design",id:"handler-design",level:2},{value:"Handlers cannot call other handlers",id:"handlers-cannot-call-other-handlers",level:3},{value:"One job per handler",id:"one-job-per-handler",level:3},{value:"Retries, timeouts, and error recovery belong in the pipeline",id:"retries-timeouts-and-error-recovery-belong-in-the-pipeline",level:3},{value:"Return scalar values, not wrapper objects",id:"return-scalar-values-not-wrapper-objects",level:3},{value:"Don't return data the pipeline already knows",id:"dont-return-data-the-pipeline-already-knows",level:3},{value:"Don't accept pass-through fields in handler inputs",id:"dont-accept-pass-through-fields-in-handler-inputs",level:3},{value:"Pass data through the pipeline, not the file system",id:"pass-data-through-the-pipeline-not-the-file-system",level:3},{value:"Pipeline composition",id:"pipeline-composition",level:2},{value:"Prefer <code>allObject</code> over <code>all</code>",id:"prefer-allobject-over-all",level:3},{value:"Use <code>bindInput</code> when multiple steps need the same value",id:"use-bindinput-when-multiple-steps-need-the-same-value",level:3},{value:"Use <code>allObject</code> to carry context forward",id:"use-allobject-to-carry-context-forward",level:3},{value:"Iteration is parallel by default",id:"iteration-is-parallel-by-default",level:3},{value:"Prefer <code>.iterate().map()</code> over <code>forEach</code>",id:"prefer-iteratemap-over-foreach",level:3},{value:"Use <code>withResource</code> for anything that needs cleanup",id:"use-withresource-for-anything-that-needs-cleanup",level:3},{value:"Handler contracts",id:"handler-contracts",level:2},{value:"Always provide validators",id:"always-provide-validators",level:3},{value:"Namespace tagged union variants",id:"namespace-tagged-union-variants",level:3},{value:"Annotate return types when returning tagged unions",id:"annotate-return-types-when-returning-tagged-unions",level:3},{value:"Use <code>null</code> not <code>undefined</code> for empty pipeline values",id:"use-null-not-undefined-for-empty-pipeline-values",level:3},{value:"Use void returns for side-effect-only handlers",id:"use-void-returns-for-side-effect-only-handlers",level:3},{value:"Minimize work inside LLM handlers",id:"minimize-work-inside-llm-handlers",level:2},{value:"Pre-read the file being modified",id:"pre-read-the-file-being-modified",level:3},{value:"Pre-read imports and dependents",id:"pre-read-imports-and-dependents",level:3}
1,{value:"Why this matters",id:"why-this-matters",level:3},{value:"Prefer postfix methods over standalone functions",id:"prefer-postfix-methods-over-standalone-functions",level:2},{value:"<code>.then()</code> vs <code>pipe()</code> for chaining",id:"then-vs-pipe-for-chaining",level:2},{value:"Use <code>taggedUnionSchema</code> for handler validators",id:"use-taggedunionschema-for-handler-validators",level:2}];function c(e){const n={code:"code",em:"em",h1:"h1",h2:"h2",h3:"h3",hr:"hr",li:"li",ol:"ol",p:"p",pre:"pre",strong:"strong",ul:"ul",...(0,a.R)(),...e.components};return(0,i.jsxs)(i.Fragment,{children:[(0,i.jsx)(n.h1,{id:"best-practices",children:"Best Practices"}),"\n",(0,i.jsx)(n.p,{children:"Three principles drive every decision below:"}),"\n",(0,i.jsxs)(n.ol,{children:["\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.strong,{children:"Use the builtins."})," The framework provides typed combinators for structuring data (",(0,i.jsx)(n.code,{children:"wrapInField"}),", ",(0,i.jsx)(n.code,{children:"getField"}),", ",(0,i.jsx)(n.code,{children:"pick"}),", ",(0,i.jsx)(n.code,{children:"allObject"}),"). Use them instead of reimplementing the same logic inside handlers."]}),"\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.strong,{children:"Move logic into the pipeline."})," Handlers do work \u2014 external calls, computation, side effects. Everything else (routing, merging, retrying, threading context) belongs in the pipeline definition where it's visible, composable, and reusable."]}),"\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.strong,{children:"Compose."})," Small, focused handlers with narrow inputs and scalar outputs combine freely. A handler that returns a flat value works with ",(0,i.jsx)(n.code,{children:"wrapInField"}),", ",(0,i.jsx)(n.code,{children:"allObject"}),", ",(0,i.jsx)(n.code,{children:"fold"}),", and every other combinator. A handler that returns a bespoke object works only with itself."]}),"\n"]}),"\n",(0,i.jsx)(n.hr,{}),"\n",(0,i.jsx)(n.h2,{id:"handler-design",children:"Handler design"}),"\n",(0,i.jsx)(n.p,{children:"Handlers are the leaf nodes \u2014 they do work. Everything else is plumbing. Keep them minimal and let the pipeline layer handle composition."}),"\n",(0,i.jsx)(n.h3,{id:"handlers-cannot-call-other-handlers",children:"Handlers cannot call other handlers"}),"\n",(0,i.jsxs)(n.p,{children:["Handlers run in isolated subprocesses. You cannot call ",(0,i.jsx)(n.code,{children:".handle()"})," from inside one handler to invoke another. All composition happens in the pipeline definition via combinators (",(0,i.jsx)(n.code,{children:"pipe"}),", ",(0,i.jsx)(n.code,{children:".then()"}),", ",(0,i.jsx)(n.code,{children:"bindInput"}),", etc.). If you need the output of one handler as input to another, chain them in the pipeline."]}),"\n",(0,i.jsx)(n.h3,{id:"one-job-per-handler",children:"One job per handler"}),"\n",(0,i.jsxs)(n.p,{children:["A handler does one thing: transform data, call an external service, read a file, invoke an LLM. All plumbing \u2014 splitting fields, merging objects, routing to different paths \u2014 belongs in the pipeline layer using ",(0,i.jsx)(n.code,{children:"bindInput"}),", ",(0,i.jsx)(n.code,{children:"getField"}),", ",(0,i.jsx)(n.code,{children:"wrapInField"}),", ",(0,i.jsx)(n.code,{children:"allObject"}),", ",(0,i.jsx)(n.code,{children:"pick"}),", and ",(0,i.jsx)(n.code,{children:"branch"}),"."]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:'// Avoid: handler does plumbing + work\nexport const analyzeAndRoute = createHandler({\n handle: async ({ value }) => {\n const result = await callClaude({ prompt: `Analyze ${value.file}` });\n // Don\'t route inside the handler \u2014 that\'s pipeline work\n if (result.severity === "critical") { ... }\n return { ...value, result }; // Don\'t merge input back in \u2014 pipeline does that\n },\n}, "analyzeAndRoute");\n\n// Prefer: handler does one thing, pipeline handles the rest\nexport const analyze = createHandler({\n inputValidator: z.object({ file: z.string() }),\n outputValidator: analysisSchema,\n handle: async ({ value }) =>
1 {\n return await callClaude({ prompt: `Analyze ${value.file}` });\n },\n}, "analyze");\n\n// Pipeline routes and merges:\nanalyze.branch({ Critical: escalate, Low: log })\n'})}),"\n",(0,i.jsx)(n.h3,{id:"retries-timeouts-and-error-recovery-belong-in-the-pipeline",children:"Retries, timeouts, and error recovery belong in the pipeline"}),"\n",(0,i.jsxs)(n.p,{children:["A handler makes exactly one attempt and returns a ",(0,i.jsx)(n.code,{children:"Result"})," on failure. Retries, timeouts, back-off, and fallback paths are all pipeline-level concerns \u2014 they compose around handlers via ",(0,i.jsx)(n.code,{children:"loop"}),", ",(0,i.jsx)(n.code,{children:"tryCatch"}),", ",(0,i.jsx)(n.code,{children:"unwrapOr"}),", and ",(0,i.jsx)(n.code,{children:"withTimeout"}),"."]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:'// Avoid: retry and timeout inside the handler\nexport const callApi = createHandler({\n handle: async ({ value }) => {\n for (let i = 0; i < 3; i++) {\n try { return await fetch(value.url, { signal: AbortSignal.timeout(5000) }); }\n catch { await sleep(1000 * i); }\n }\n throw new Error("failed after retries");\n },\n}, "callApi");\n\n// Prefer: handler does one attempt, pipeline handles retry and timeout\nexport const callApi = createHandler({\n outputValidator: Result.schema(responseSchema, z.string()),\n handle: async ({ value }) => {\n try { return { kind: "Result.Ok", value: await fetch(value.url) }; }\n catch (e) { return { kind: "Result.Err", value: e.message }; }\n },\n}, "callApi");\n\n// Pipeline adds timeout and retries:\nloop((recur, done) =>\n withTimeout(constant(5_000), callApi)\n .branch({ Ok: done, Err: logAndWait.then(recur) })\n)\n'})}),"\n",(0,i.jsxs)(n.p,{children:["This separation means you can reuse ",(0,i.jsx)(n.code,{children:"callApi"})," in contexts that don't want retries, or change the retry strategy without touching the handler."]}),"\n",(0,i.jsx)(n.h3,{id:"return-scalar-values-not-wrapper-objects",children:"Return scalar values, not wrapper objects"}),"\n",(0,i.jsxs)(n.p,{children:["A handler that computes one thing should return that thing directly \u2014 not an object wrapping it. Wrapping is the pipeline's job (",(0,i.jsx)(n.code,{children:"wrapInField"}),", ",(0,i.jsx)(n.code,{children:"allObject"}),"). Scalar outputs compose with every combinator; bespoke objects only work in one pipeline."]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:'// Avoid: handler wraps its result in an object\nexport const countLines = createHandler({\n inputValidator: z.object({ file: z.string() }),\n outputValidator: z.object({ lineCount: z.number() }),\n handle: async ({ value }) => {\n const content = readFileSync(value.file, "utf-8");\n return { lineCount: content.split("\\n").length };\n },\n}, "countLines");\n\n// Prefer: return the value directly, let the pipeline structure it\nexport const countLines = createHandler({\n inputValidator: z.object({ file: z.string() }),\n outputValidator: z.number(),\n handle: async ({ value }) => {\n const content = readFileSync(value.file, "utf-8");\n return content.split("\\n").length;\n },\n}, "countLines");\n\n// Pipeline wraps/merges as needed:\ncountLines.wrapInField("lineCount") // \u2192 { lineCount: number }\nallObject({ lines: countLines, size: getFileSize }) // combine multiple\n'})}),"\n",(0,i.jsx)(n.h3,{id:"dont-return-data-the-pipeline-already-knows",children:"Don't return data the pipeline already knows"}),"\n",(0,i.jsxs)(n.p,{children:["If the file path was passed in as input, don't make the handler echo it back. The pipeline can merge it back via ",(0,i.jsx)(n.code,{children:"wrapInField"}),", ",(0,i.jsx)(n.code,{children:"allObject"}),", or ",(0,i.jsx)(n.code,{children:"bindInput"}),"."]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:'// Avoid: handler parrots its input back\nexport const countLines = createHandler({\n handle: async ({ value }) => {\n const content = readFileSync(value.file, "utf-8");\n return { file: value.file, lineCount: content.split("\\n").length };\n // ^^^^^^^^^^^^^^^ pipeline already has this\n },\n}, "countLines");\n\n// Prefer: handler returns only what it computed\nexport const countLines = createHandler({\n inputValidator: z.object({ file: z.string() }),\n outputValidator: z.number(),\n handle: async ({ value }) =>
1 {\n const content = readFileSync(value.file, "utf-8");\n return content.split("\\n").length;\n },\n}, "countLines");\n\n// Pipeline structures the result:\nallObject({ file: identity(), lineCount: countLines }) // { file } \u2192 { file, lineCount }\n'})}),"\n",(0,i.jsx)(n.h3,{id:"dont-accept-pass-through-fields-in-handler-inputs",children:"Don't accept pass-through fields in handler inputs"}),"\n",(0,i.jsxs)(n.p,{children:["Handlers should only accept the fields they actually use. A handler that accepts data and returns it unchanged is an anti-pattern \u2014 it means the handler is doing the pipeline's job of threading context. The telltale sign is ",(0,i.jsx)(n.code,{children:"return { ...value, result }"})," or any handler whose output is a superset of its input with one new field tacked on."]}),"\n",(0,i.jsxs)(n.p,{children:["If a handler needs ",(0,i.jsx)(n.code,{children:"file"})," but downstream steps also need ",(0,i.jsx)(n.code,{children:"branch"})," and ",(0,i.jsx)(n.code,{children:"worktreePath"}),", don't widen the handler's input to include all three. Use ",(0,i.jsx)(n.code,{children:"bindInput"})," or ",(0,i.jsx)(n.code,{children:"pick"})," in the pipeline to narrow the input before the handler and restore the full context after."]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:'// Avoid: handler accepts fields it doesn\'t use, just to pass them through\nexport const analyze = createHandler({\n inputValidator: z.object({\n file: z.string(),\n branch: z.string(), // not used by analyze\n worktreePath: z.string(), // not used by analyze\n }),\n outputValidator: z.object({\n file: z.string(), // echoed back unchanged\n branch: z.string(), // echoed back unchanged\n worktreePath: z.string(), // echoed back unchanged\n issues: z.array(issueSchema),\n }),\n handle: async ({ value }) => {\n const issues = await findIssues(value.file);\n return { ...value, issues }; // spreading input into output = anti-pattern\n },\n}, "analyze");\n'})}),"\n",(0,i.jsxs)(n.p,{children:["The handler's signature is now coupled to its caller's context. It can't be reused in a pipeline that doesn't have ",(0,i.jsx)(n.code,{children:"branch"})," or ",(0,i.jsx)(n.code,{children:"worktreePath"}),". Instead, keep the handler's input narrow and let the pipeline manage context:"]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:'// Prefer: handler only accepts what it needs, returns only what it computed\nexport const analyze = createHandler({\n inputValidator: z.object({ file: z.string() }),\n outputValidator: z.array(issueSchema),\n handle: async ({ value }) => {\n return await findIssues(value.file);\n },\n}, "analyze");\n\n// Pipeline narrows input and restores context:\nbindInput<{ file: string; branch: string; worktreePath: string }>((params) =>\n params.pick("file").then(analyze)\n .then(wrapInField("issues"))\n // params still has branch and worktreePath available for later steps\n)\n\n// Or use allObject to combine input fields with handler output:\nallObject({ file: identity(), issues: analyze })\n'})}),"\n",(0,i.jsx)(n.p,{children:"This keeps handlers reusable, testable in isolation, and decoupled from the specific pipeline they appear in. The pipeline is the right place for context management \u2014 handlers are the right place for doing work."}),"\n",(0,i.jsx)(n.h3,{id:"pass-data-through-the-pipeline-not-the-file-system",children:"Pass data through the pipeline, not the file system"}),"\n",(0,i.jsx)(n.p,{children:"The pipeline is the data channel between handlers. If handler A produces a result that handler B needs, return it from A and pass it to B through the pipeline \u2014 don't write it to a temp file and have B read it back."}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:'// Avoid: using the file system as a data bus\nexport const generateReport = createHandler({\n handle: async ({ value }) => {\n const report = await analyze(value.file);\n writeFileSync("/tmp/report.json", JSON.stringify(report));\n },\n}, "generateReport");\n\nexport const publishReport = createHandler({\n handle: async () => {\n const report = JSON.parse(readFileSync("/tmp/report.json", "utf-8"));\n await upload(report);\n },\n}, "publishReport");\n\n// Prefer: data flows through the pipeline\nexport const generateReport = createHandler({\n inputValidator: z.object({ file: z.string() }),\n outputValidator: reportSchema,\n handle: async ({ value }) =>
1 {\n return await analyze(value.file);\n },\n}, "generateReport");\n\nexport const publishReport = createHandler({\n inputValidator: reportSchema,\n handle: async ({ value }) => {\n await upload(value);\n },\n}, "publishReport");\n\n// Pipeline connects them:\ngenerateReport.then(publishReport)\n'})}),"\n",(0,i.jsxs)(n.p,{children:["File system writes are appropriate for ",(0,i.jsx)(n.strong,{children:"durable side effects"})," \u2014 checkpointing progress, writing final output artifacts, persisting state that survives process crashes. They are not appropriate for passing intermediate data between pipeline steps. Pipeline data is typed, validated, and visible to the framework for debugging and replay. File system state is opaque, fragile, and couples handlers to specific paths."]}),"\n",(0,i.jsx)(n.hr,{}),"\n",(0,i.jsx)(n.h2,{id:"pipeline-composition",children:"Pipeline composition"}),"\n",(0,i.jsxs)(n.h3,{id:"prefer-allobject-over-all",children:["Prefer ",(0,i.jsx)(n.code,{children:"allObject"})," over ",(0,i.jsx)(n.code,{children:"all"})]}),"\n",(0,i.jsxs)(n.p,{children:[(0,i.jsx)(n.code,{children:"all"})," returns a positional tuple \u2014 callers access results by index, which is fragile and unreadable. ",(0,i.jsx)(n.code,{children:"allObject"})," returns a named object:"]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:"// Avoid: positional tuple \u2014 what's [0]? what's [1]?\nall(listFiles, loadConfig, readManifest)\n// \u2192 [string[], Config, Manifest]\n\n// Prefer: named fields \u2014 self-documenting, refactor-safe\nallObject({\n files: listFiles,\n config: loadConfig,\n manifest: readManifest,\n})\n// \u2192 { files: string[], config: Config, manifest: Manifest }\n"})}),"\n",(0,i.jsxs)(n.p,{children:["Named fields survive reordering and additions without breaking downstream ",(0,i.jsx)(n.code,{children:".getIndex()"})," calls. Use ",(0,i.jsx)(n.code,{children:"all"})," only when feeding directly into something that expects a tuple (like ",(0,i.jsx)(n.code,{children:"fold"}),"'s ",(0,i.jsx)(n.code,{children:"[acc, element]"}),")."]}),"\n",(0,i.jsxs)(n.h3,{id:"use-bindinput-when-multiple-steps-need-the-same-value",children:["Use ",(0,i.jsx)(n.code,{children:"bindInput"})," when multiple steps need the same value"]}),"\n",(0,i.jsxs)(n.p,{children:["If a handler's output is consumed by one step but also needed later (e.g., a worktree path used for type-check, commit, and PR creation), wrap the section in ",(0,i.jsx)(n.code,{children:"bindInput"})," rather than threading the value through every handler's input/output."]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:'// Avoid: every handler accepts and returns worktreePath\nimplement.then(typeCheck).then(commit).then(createPR)\n// Each handler must include worktreePath in its input AND output \u2014 coupling city\n\n// Prefer: bindInput captures the shared context\nbindInput<Params>((params) =>\n params.pick("worktreePath", "description").then(implement).drop()\n .then(params.pick("worktreePath").then(typeCheckFix).drop())\n .then(params.pick("worktreePath").then(commit).drop())\n .then(params.pick("branch", "description").then(createPR))\n)\n'})}),"\n",(0,i.jsxs)(n.h3,{id:"use-allobject-to-carry-context-forward",children:["Use ",(0,i.jsx)(n.code,{children:"allObject"})," to carry context forward"]}),"\n",(0,i.jsxs)(n.p,{children:["When you need both a handler's input and output downstream, use ",(0,i.jsx)(n.code,{children:"allObject"})," to run the handler alongside ",(0,i.jsx)(n.code,{children:"identity()"})," and collect the results into a named object."]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:'// { file: string } \u2192 { file: string, lineCount: number }\nlistFiles.iterate().map(allObject({ file: getField("file"), lineCount: countLines })).collect()\n'})}),"\n",(0,i.jsx)(n.h3,{id:"iteration-is-parallel-by-default",children:"Iteration is parallel by default"}),"\n",(0,i.jsxs)(n.p,{children:[(0,i.jsx)(n.code,{children:".iterate().map(action).c
1ollect()"})," dispatches all elements concurrently \u2014 like ",(0,i.jsx)(n.code,{children:"Promise.all"}),", not a for-loop. If you need sequential processing (e.g., each step depends on the previous result, or you're rate-limited), use ",(0,i.jsx)(n.code,{children:".fold()"}),":"]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:"// Parallel: all files processed concurrently\nlistFiles.iterate().map(processFile).collect()\n\n// Sequential: one at a time, with accumulator\nlistFiles.iterate().fold(constant(initialState), processFileSequentially)\n"})}),"\n",(0,i.jsxs)(n.p,{children:["There is no sequential ",(0,i.jsx)(n.code,{children:".each()"})," or sequential ",(0,i.jsx)(n.code,{children:".map()"}),". If you want one-at-a-time execution, fold is the primitive."]}),"\n",(0,i.jsxs)(n.h3,{id:"prefer-iteratemap-over-foreach",children:["Prefer ",(0,i.jsx)(n.code,{children:".iterate().map()"})," over ",(0,i.jsx)(n.code,{children:"forEach"})]}),"\n",(0,i.jsxs)(n.p,{children:[(0,i.jsx)(n.code,{children:"forEach"})," is a low-level AST node. The Iterator API is the user-facing equivalent with better composability \u2014 you can chain ",(0,i.jsx)(n.code,{children:".filter()"}),", ",(0,i.jsx)(n.code,{children:".take()"}),", ",(0,i.jsx)(n.code,{children:".flatMap()"})," before collecting."]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:"// Avoid: raw forEach, no ability to filter/take/transform\nforEach(processFile)\n\n// Prefer: full Iterator API\nlistFiles.iterate().filter(isRelevant).take(10).map(processFile).collect()\n"})}),"\n",(0,i.jsxs)(n.h3,{id:"use-withresource-for-anything-that-needs-cleanup",children:["Use ",(0,i.jsx)(n.code,{children:"withResource"})," for anything that needs cleanup"]}),"\n",(0,i.jsxs)(n.p,{children:["Git worktrees, temp directories, database connections \u2014 if it needs teardown regardless of success/failure, use ",(0,i.jsx)(n.code,{children:"withResource"})," rather than manual try/finally logic inside a handler."]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:"withResource({\n create: createBranchWorktree,\n action: implementAndReview,\n dispose: deleteWorktree,\n})\n"})}),"\n",(0,i.jsxs)(n.p,{children:["The ",(0,i.jsx)(n.code,{children:"dispose"})," step runs whether ",(0,i.jsx)(n.code,{children:"action"})," succeeds or fails \u2014 guaranteed cleanup without polluting handler logic."]}),"\n",(0,i.jsx)(n.hr,{}),"\n",(0,i.jsx)(n.h2,{id:"handler-contracts",children:"Handler contracts"}),"\n",(0,i.jsx)(n.h3,{id:"always-provide-validators",children:"Always provide validators"}),"\n",(0,i.jsxs)(n.p,{children:["Always provide ",(0,i.jsx)(n.code,{children:"inputValidator"})," and ",(0,i.jsx)(n.code,{children:"outputValidator"})," on handlers even though they're optional. They serve as machine-checked documentation of the handler's contract and catch shape mismatches at runtime boundaries."]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:'// Avoid: no validators \u2014 silent failures when shapes don\'t match\nexport const analyze = createHandler({\n handle: async ({ value }) => { ... },\n}, "analyze");\n\n// Prefer: validators document and enforce the contract\nexport const analyze = createHandler({\n inputValidator: z.object({ file: z.string() }),\n outputValidator: z.array(refactorSchema),\n handle: async ({ value }) => { ... },\n}, "analyze");\n'})}),"\n",(0,i.jsx)(n.h3,{id:"namespace-tagged-union-variants",children:"Namespace tagged union variants"}),"\n",(0,i.jsxs)(n.p,{children:['When a handler returns a decision (e.g., "needs work" vs "approved"), namespace the variants in ',(0,i.jsx)(n.code,{children:"taggedUnionSchema"}),". This prevents collisions when multiple branch points exist in the same pipeline and makes branch dispatch unambiguous."]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:'// Handler returns a namespaced decision:\noutputValidator: taggedUnionSchema("Judgment", {\n NeedsWork: feedbackSchema,\n Approved: z.null(),\
1n})\n\n// Branch dispatches on the short names:\nclassifyJudgment.branch({\n NeedsWork: applyFeedback.then(recur),\n Approved: drop,\n})\n'})}),"\n",(0,i.jsx)(n.h3,{id:"annotate-return-types-when-returning-tagged-unions",children:"Annotate return types when returning tagged unions"}),"\n",(0,i.jsxs)(n.p,{children:["When a handler returns a tagged union but only constructs one variant in a given code path, TypeScript narrows the return type to that single variant. The pipeline then fails to typecheck because the handler's output type is narrower than the full union expected by ",(0,i.jsx)(n.code,{children:".branch()"})," or ",(0,i.jsx)(n.code,{children:".unwrapOr()"}),"."]}),"\n",(0,i.jsxs)(n.p,{children:["Fix: add an explicit ",(0,i.jsx)(n.code,{children:"Promise<FullUnionType>"})," return type annotation to the ",(0,i.jsx)(n.code,{children:"handle"})," function."]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:'type AnalysisResult = Result<string, string>;\n\n// Avoid: TypeScript narrows to
1just the Ok variant\nhandle: async ({ value }) => {\n return { kind: "Result.Ok" as const, value: "done" };\n // Inferred return: { kind: "Result.Ok", value: string } \u2014 not Result<string, string>\n}\n\n// Prefer: explicit annotation preserves the full union\nhandle: async ({ value }): Promise<AnalysisResult> => {\n return { kind: "Result.Ok" as const, value: "done" };\n}\n'})}),"\n",(0,i.jsxs)(n.h3,{id:"use-null-not-undefined-for-empty-pipeline-values",children:["Use ",(0,i.jsx)(n.code,{children:"null"})," not ",(0,i.jsx)(n.code,{children:"undefined"})," for empty pipeline values"]}),"\n",(0,i.jsxs)(n.p,{children:["Pipeline definitions are serialized to JSON. ",(0,i.jsx)(n.code,{children:"undefined"})," has no JSON representation \u2014 ",(0,i.jsx)(n.code,{children:"JSON.stringify({ value: undefined })"})," produces ",(0,i.jsx)(n.code,{children:"{}"}),", which causes the Rust deserializer to fail with a missing field error. TypeScript won't catch this because ",(0,i.jsx)(n.code,{children:"void"})," accepts ",(0,i.jsx)(n.code,{children:"undefined"})," as a valid value."]}),"\n",(0,i.jsxs)(n.p,{children:["Use ",(0,i.jsx)(n.code,{children:"null"}),' for "no meaningful value" in pipeline data:']}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:"// Broken: undefined disappears during serialization\nSkip: bindInput<null, void>(() => constant(undefined))\n\n// Fixed: null serializes correctly\nSkip: bindInput<null, null>(() => constant(null))\n"})}),"\n",(0,i.jsxs)(n.p,{children:["This applies anywhere you construct pipeline values \u2014 ",(0,i.jsx)(n.code,{children:"constant()"}),', handler return values, branch cases. If you mean "nothing," use ',(0,i.jsx)(n.code,{children:"null"}),"."]}),"\n",(0,i.jsx)(n.h3,{id:"use-void-returns-for-side-effect-only-handlers",children:"Use void returns for side-effect-only handlers"}),"\n",(0,i.jsxs)(n.p,{children:["If a handler's purpose is a side effect (write a file, send a message, invoke an LLM with tools), return ",(0,i.jsx)(n.code,{children:"void"})," from ",(0,i.jsx)(n.code,{children:"handle"}),". The framework types it as ",(0,i.jsx)(n.code,{children:"never"})," output \u2014 the next step starts fresh via ",(0,i.jsx)(n.code,{children:".drop()"})," or naturally from a new source. Don't return ",(0,i.jsx)(n.code,{children:"null"})," and pass it along."]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:'// Avoid: returning null as a meaningless value that gets threaded through\nexport const implement = createHandler({\n outputValidator: z.null(),\n handle: async ({ value }) => {\n await callClaude({ prompt: `Implement ${value.description}` });\n return null;\n },\n}, "implement");\n\n// Prefer: void return \u2014 framework knows there\'s no output\nexport const implement = createHandler({\n inputValidator: z.object({ description: z.string() }),\n handle: async ({ value }) => {\n await callClaude({ prompt: `Implement ${value.description}` });\n },\n}, "implement");\n'})}),"\n",(0,i.jsx)(n.hr,{}),"\n",(0,i.jsx)(n.h2,{id:"minimize-work-inside-llm-handlers",children:"Minimize work inside LLM handlers"}),"\n",(0,i.jsxs)(n.p,{children:["When a handler invokes an LLM agent (e.g., ",(0,i.jsx)(n.code,{children:"callClaude"}),"), the agent's effectiveness is bounded by the context it receives. Pre-read files in earlier pipeline steps and pass the content as input \u2014 don't make the agent spend tokens discovering information you already have."]}),"\n",(0,i.jsx)(n.h3,{id:"pre-read-the-file-being-modified",children:"Pre-read the file being modified"}),"\n",(0,i.jsx)(n.p,{children:"If the agent's job is to modify a file, read it before the handler runs and pass the content in. The agent sees the full file immediately instead of burning a tool call to read it."}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:'// Avoid: agent wastes a tool call reading the file\nexport const refactor = createHandler({\n inputValidator: z.object({ file: z.string() }),\n handle: async ({ value }) =>
1 {\n await callClaude({\n prompt: `Refactor ${value.file}`,\n allowedTools: ["Read", "Edit"],\n });\n },\n}, "refactor");\n\n// Prefer: pre-read in the pipeline, agent starts with full context\nexport const readFile = createHandler({\n inputValidator: z.object({ file: z.string() }),\n outputValidator: z.object({ file: z.string(), content: z.string() }),\n handle: async ({ value }) => ({\n file: value.file,\n content: readFileSync(value.file, "utf-8"),\n }),\n}, "readFile");\n\nexport const refactor = createHandler({\n inputValidator: z.object({ file: z.string(), content: z.string() }),\n handle: async ({ value }) => {\n await callClaude({\n prompt: `Refactor this file (${value.file}):\\n\\n${value.content}`,\n allowedTools: ["Edit"],\n });\n },\n}, "refactor");\n'})}),"\n",(0,i.jsx)(n.h3,{id:"pre-read-imports-and-dependents",children:"Pre-read imports and dependents"}),"\n",(0,i.jsx)(n.p,{children:"An agent modifying a file needs to understand its dependencies and its callers. Read these in the pipeline and include them:"}),"\n",(0,i.jsxs)(n.ul,{children:["\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.strong,{children:"Files it imports"})," \u2014 so the agent knows the shape of dependencies without guessing."]}),"\n",(0,i.jsxs)(n.li,{children:[(0,i.jsx)(n.strong,{children:"Files that import it"})," \u2014 so the agent understands downstream callers and avoids breaking changes."]}),"\n"]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:'// Pipeline reads context, agent receives it pre-loaded:\nreadTargetFile.bindInput((fileAndContent) =>\n allObject({\n file: fileAndContent.getField("file"),\n content: fileAndContent.getField("content"),\n imports: fileAndContent.then(resolveImports),\n dependents: fileAndContent.then(findDependents),\n }).then(refactorWithContext)\n)\n'})}),"\n",(0,i.jsx)(n.h3,{id:"why-this-matters",children:"Why this matters"}),"\n",(0,i.jsxs)(n.p,{children:["Every tool call an LLM agent makes costs latency and tokens. A ",(0,i.jsx)(n.code,{children:"Read"})," call the agent makes inside a handler is identical work the pipeline could have done deterministically in milliseconds. The agent should spend its budget on judgment and creativity \u2014 deciding ",(0,i.jsx)(n.em,{children:"what"})," to change \u2014 not on mechanically gathering files it was always going to need."]}),"\n",(0,i.jsx)(n.hr,{}),"\n",(0,i.jsx)(n.h2,{id:"prefer-postfix-methods-over-standalone-functions",children:"Prefer postfix methods over standalone functions"}),"\n",(0,i.jsxs)(n.p,{children:["When a combinator is available as both a standalone function and a postfix method, ",(0,i.jsx)(n.strong,{children:"always prefer the postfix form."})," Two reasons:"]}),"\n",(0,i.jsxs)(n.ol,{children:["\n",(0,i.jsxs)(n.li,{children:["\n",(0,i.jsxs)(n.p,{children:[(0,i.jsx)(n.strong,{children:"No type parameters."})," Standalone functions like ",(0,i.jsx)(n.code,{children:"getField<TObj, TField>(field)"})," often require explicit generic arguments because TypeScript can't infer the input type without context. The postfix form ",(0,i.jsx)(n.code,{children:'action.getField("name")'})," infers everything from the preceding action's output type \u2014 zero annotation needed."]}),"\n"]}),"\n",(0,i.jsxs)(n.li,{children:["\n",(0,i.jsxs)(n.p,{children:[(0,i.jsxs)(n.strong,{children:["No wrapping in ",(0,i.jsx)(n.code,{children:"pipe"}),"."]})," Standalone functions used mid-pipeline need a ",(0,i.jsx)(n.code,{children:'pipe(action, getField("name"))'})," wrapper. Postfix chains directly: ",(0,i.jsx)(n.code,{children:'action.getField("name")'}),"."]}),"\n"]}),"\n"]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:'// Avoid: standalone requires type parameters and pipe wrapping\npipe(getUserProfile, getField<UserProfile, "email">("email"))\n\n// Prefer: postfix infers types from context\ngetUserProfile.getField("email")\n'})}),"\n",(0,i.jsxs)(n.p,{children:["This applies to every combinator that has a postfix form: ",(0,i.jsx)(n.code,{children:".then()"}),", ",(0,i.jsx)(n.code,{children:".iterate()"}),", ",(0,i.jsx)(n.code,{children:".map()"}),", ",(0,i.jsx)(n.code,{children:".flatMap()"}),", ",(0,i.jsx)(n.code,{children:".filter()"}),", ",(0,i.jsx)(n.code,{children:".collect()"}),", ",(0,i.jsx)(n.code,{children:".branch()"}),", ",(0,i.jsx)(n.code,{children:".drop()"}),", ",(0,i.jsx)(n.code,{children:".tag()"}),", ",(0,i.jsx)(n.code,{children:".flatten()"}),", ",(0,i.jsx)(n.code,{children:".getField()"}),", ",(0,i.jsx)(n.code,{children:".getIndex()"}),", ",(0,i.jsx)(n.code,{children:".pick()"}),", ",(0,i.jsx)(n.code,{children:".wrapInField()"}),", ",(0,i.jsx)(n.code,{children:".splitFirst()"}),", ",(0,i.jsx)(n.code,{children:".splitLast()"}),", ",(0,i.jsx)(n.code,{children:".mapErr()"}),", ",(0,i.jsx)(n.code,{children:".unwrapOr()"}),"."]}),"\n",(0,i.jsxs)(n.h2,{id:"then-vs-pipe-for-chaining",children:[(0,i.jsx)(n.code,{children:".then()"})," vs ",(0,i.jsx)(n.code,{children:"pipe()"})," for chaining"]}),"\n",(0,i.jsxs)(n.p,{children:["Use ",(0,i.jsx)(n.code,{children:".then()"})," for two-step chains. Use ",(0,i.jsx)(n.code,{children:"pipe()"})," when chaining three or more steps in sequence \u2014 it's flatter and easier to scan:"]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:"// Two steps: .then() is fine\nlistFiles.then(commit)\n\n// Three+ steps: use pipe()\npipe(\n listFiles,\n processFiles,\n commit,\n)\n\n// Avoid: long .then() chains\nlistFiles.then(processFiles).then(validate).then(commit)\n\n// Avoid: mixing pipe and .then()\npipe(listFiles, processFiles).then(commit)\n"})}),"\n",(0,i.jsxs)(n.p,{children:["Postfix methods like ",(0,i.jsx)(n.code,{children:".iterate()"}),", ",(0,i.jsx)(n.code,{children:".map()"}),", ",(0,i.jsx)(n.code,{children:".collect()"}),", ",(0,i.jsx)(n.code,{children:".getField()"}),", ",(0,i.jsx)(n.code,{children:".branch()"})," are always preferred over their standalone equivalents regardless of chain length \u2014 they infer types from context and don't require explicit type parameters."]}),"\n",(0,i.jsxs)(n.h2,{id:"use-taggedunionschema-for-handler-validators",children:["Use ",(0,i.jsx)(n.code,{children:"taggedUnionSchema"})," for handler validators"]}),"\n",(0,i.jsxs)(n.p,{children:["When a handler returns a tagged union, use ",(0,i.jsx)(n.code,{children:"taggedUnionSchema()"}),", ",(0,i.jsx)(n.code,{children:"Option.schema()"}),", or ",(0,i.jsx)(n.code,{children:"Result.schema()"})," instead of hand-rolling ",(0,i.jsx)(n.code,{children:"z.discriminatedUnion()"}),":"]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:'// Avoid\noutputValidator: z.discriminatedUnion("kind", [\n z.object({ kind: z.literal("HasErrors"), value: z.array(errorSchema) }),\n z.object({ kind: z.literal("Clean"), value: z.null() }),\n])\n\n// Prefer\noutputValidator: taggedUnionSchema({\n HasErrors: z.array(errorSchema),\n Clean: z.null(),\
1n})\n'})}),"\n",(0,i.jsxs)(n.p,{children:["For ",(0,i.jsx)(n.code,{children:"Option"})," and ",(0,i.jsx)(n.code,{children:"Result"})," specifically:"]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-ts",children:"outputValidator: Option.schema(z.string()) // Option<string>\noutputValidator: Result.schema(z.string(), z.number()) // Result<string, number>\n"})})]})}function h(e={}){const{wrapper:n}={...(0,a.R)(),...e.components};return n?(0,i.jsx)(n,{...e,children:(0,i.jsx)(c,{...e})}):c(e)}},9236(e,n,t){t.d(n,{R:()=>l,x:()=>s});var i=t(7378);const a={},r=i.createContext(a);function l(e){const n=i.useContext(r);return i.useMemo(function(){return"function"==typeof e?e(n):{...n,...e}},[n,e])}function s(e){let n;return n=e.disableParentContext?"function"==typeof e.components?e.components(a):e.components||a:l(e.components),i.createElement(r.Provider,{value:n},e.children)}}}]);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.