Skip to main content
Version: main

Builtins

Barnum provides typed combinators for composing workflows. TypeScript tracks input and output types through the entire pipeline via phantom types.

Every combinator is either a standalone function (imported from barnum) or a postfix method on TypedAction (chained via .method()), or both. When both exist, prefer the postfix form β€” it infers all types from the preceding action's output, eliminating explicit type parameters. See Best Practices for details.

Control flow​

pipe(...actions)​

Sequential composition. The output of each action becomes the input of the next.

function pipe<T1, T2, T3>(
a: Pipeable<T1, T2>,
b: Pipeable<T2, T3>,
): TypedAction<T1, T3>

Up to 10 actions. Zero arguments returns identity; one argument wraps the action.

Postfix: .then(next) chains a single action.

listFiles.iterate().map(processFile).collect().then(commit)

all(...actions)​

Run multiple actions concurrently on the same input. Collects outputs as a tuple.

function all<T1, A, B, C>(
a: Pipeable<T1, A>,
b: Pipeable<T1, B>,
c: Pipeable<T1, C>,
): TypedAction<T1, [A, B, C]>

Up to 10 actions. Zero arguments returns TypedAction<any, []>.

Postfix: No.

all(analyzeStyle, analyzeLogic, analyzeSecurity)
// Input: string β†’ Output: [StyleReport, LogicReport, SecurityReport]

chain(first, rest)​

Binary chain. Equivalent to pipe(first, rest).

function chain<T1, T2, T3>(
first: Pipeable<T1, T2>,
rest: Pipeable<T2, T3>,
): TypedAction<T1, T3>

Postfix: .then(rest) is the postfix equivalent.


Iterator methods​

The Iterator pattern is the preferred way to transform collections. It provides .map(), .flatMap(), .filter(), and .collect() β€” all building on the ForEach AST node internally.

.iterate() β€” enter Iterator​

Converts an array, Option, or Result into an Iterator<T> via branchFamily dispatch.

// On arrays: T[] β†’ Iterator<T>
// On Option<T>: Some β†’ Iterator with one element, None β†’ empty Iterator
// On Result<T, E>: Ok β†’ Iterator with one element, Err β†’ empty Iterator

Postfix: Yes β€” .iterate(). Available when output is T[], Option<T>, or Result<T, E>.

listFiles.iterate()           // TypedAction<void, Iterator<string>>
option.iterate() // TypedAction<In, Iterator<T>>
result.iterate() // TypedAction<In, Iterator<T>>

.map(f) β€” transform each element​

Apply an action to each element (parallel). Stays in Iterator.

// Iterator<T> β†’ Iterator<U>

Postfix: Yes β€” .map(action). Available when output is Iterator<T>, Option<T>, or Result<T, E>.

listFiles.iterate().map(processFile)
// Iterator<string> β†’ Iterator<ProcessResult>

.flatMap(f) β€” flat-map with IntoIterator​

Apply an action to each element where f returns any IntoIterator type (Iterator, Option, Result, or array). Results are flattened into a single Iterator.

// Iterator<T> β†’ Iterator<U>
// f can return: U[], Option<U>, Result<U, E>, or Iterator<U>

Postfix: Yes β€” .flatMap(action). Available when output is Iterator<T>.

files.iterate().flatMap(analyze)
// analyze: File β†’ Refactor[] (array is IntoIterator), results concatenated

.filter(pred) β€” keep matching elements​

Keep elements where the predicate returns true.

// Iterator<T> β†’ Iterator<T>
// pred: T β†’ boolean

Postfix: Yes β€” .filter(predicate). Available when output is Iterator<T>.

files.iterate().filter(isRelevant)
// Iterator<File> β†’ Iterator<File> (only relevant files kept)

.collect() β€” exit Iterator​

Unwrap Iterator back to a plain array.

// Iterator<T> β†’ T[]

Postfix: Yes β€” .collect(). Available when output is Iterator<T>.

listFiles.iterate().map(processFile).collect()
// Iterator<ProcessResult> β†’ ProcessResult[]

Standalone constructors​

Iterator.fromArray<TElement>()    // T[] β†’ Iterator<T>
Iterator.fromOption<TElement>() // Option<T> β†’ Iterator<T>
Iterator.fromResult<TElement, TError>() // Result<T, E> β†’ Iterator<T>
Iterator.map<TIn, TOut>(action) // Iterator<TIn> β†’ Iterator<TOut>
Iterator.flatMap<TIn, TOut>(action) // Iterator<TIn> β†’ Iterator<TOut>
Iterator.filter<T>(predicate) // Iterator<T> β†’ Iterator<T>
Iterator.collect<T>() // Iterator<T> β†’ T[]

forEach(action) (low-level)​

Apply an action to each element of an array, concurrently. This is the underlying ForEach AST node β€” prefer Iterator methods (.iterate().map(f).collect()) for user-facing code.

function forEach<TIn, TOut>(
action: Pipeable<TIn, TOut>,
): TypedAction<TIn[], TOut[]>

Postfix: Yes β€” .forEach(action). Available but prefer .iterate().map(action).collect().

// Low-level (still works):
listFiles.then(forEach(processFile))

// Preferred:
listFiles.iterate().map(processFile).collect()

branch(cases)​

Dispatch on a tagged union's kind field. Each variant maps to a handler that receives the unwrapped value.

function branch<TCases extends Record<string, Action>>(
cases: TCases,
): TypedAction<BranchInput<TCases>, ExtractOutput<TCases[keyof TCases]>>

All case handlers must produce the same output type.

Postfix: Yes β€” .branch(cases). Prefer postfix β€” it infers variant keys and payload types from the preceding action's tagged union output.

classify.branch({
NeedsRefactor: refactor,
Clean: drop,
})

loop(bodyFn)​

Iterative loop. The body receives recur (restart with new input) and done (break with value). The body must never complete normally β€” it always calls recur or done.

function loop<TBreak, TIn>(
bodyFn: (
recur: TypedAction<TIn, never>,
done: TypedAction<TBreak, never>,
) => Pipeable<TIn, never>,
): TypedAction<TIn, TBreak>

Postfix: No.

loop((recur, done) =>
typeCheck.then(classifyErrors).branch({
HasErrors: fix.then(recur),
Clean: done,
})
)

tryCatch(body, recovery)​

Type-level error handling. The body receives a throwError token; firing it routes to the recovery arm. Both arms must return the same type.

function tryCatch<TIn, TOut, TError>(
body: (throwError: TypedAction<TError, never>) => Pipeable<TIn, TOut>,
recovery: Pipeable<TError, TOut>,
): TypedAction<TIn, TOut>

Handles type-level errors only (not exceptions/panics).

Postfix: No.

tryCatch(
(throwError) => riskyStep.unwrapOr(throwError),
fallbackStep,
)

race(...actions)​

Run actions concurrently. First to complete wins; others are cancelled.

function race<TIn, TOut>(
...actions: Pipeable<TIn, TOut>[]
): TypedAction<TIn, TOut>

All actions must have identical input and output types.

Postfix: No.


withTimeout(ms, body)​

Race an action against a timer. Returns Result<TOut, void>.

function withTimeout<TIn, TOut>(
ms: Pipeable<TIn, number>,
body: Pipeable<TIn, TOut>,
): TypedAction<TIn, Result<TOut, void>>

Ok(value) if body completes, Err(void) on timeout.

Postfix: No.

withTimeout(constant(30_000), slowStep)

earlyReturn(bodyFn)​

Create a scope with an early exit. The body receives an earlyReturn token. Output type is the union of normal completion and early return.

function earlyReturn<TEarlyReturn, TIn, TOut>(
bodyFn: (
earlyReturn: TypedAction<TEarlyReturn, never>,
) => Pipeable<TIn, TOut>,
): TypedAction<TIn, TEarlyReturn | TOut>

Postfix: No.


recur(bodyFn)​

Restartable scope. The body receives a restart token that re-executes the body from the beginning with new input.

function recur<TIn, TOut>(
bodyFn: (
restart: TypedAction<TIn, never>,
) => Pipeable<TIn, TOut>,
): TypedAction<TIn, TOut>

Postfix: No.


sleep()​

Delay for the number of milliseconds specified by the input. Cancellable during race teardown.

function sleep(): TypedAction<number, void>

Postfix: No.


bind(bindings, body)​

Bind concurrent values as typed references (VarRef). All bindings are evaluated concurrently; the body receives an array of typed references that can be dereferenced anywhere in the pipeline.

function bind<TBindings extends Action[], TOut>(
bindings: [...TBindings],
body: (vars: InferVarRefs<TBindings>) => BodyResult<TOut>,
): TypedAction<ExtractInput<TBindings[number]>, TOut>

Postfix: No.

bind([getConfig, getUser], ([configRef, userRef]) =>
processWithConfig(configRef).then(notifyUser(userRef))
)

bindInput(body)​

Capture the pipeline input as a VarRef for later reference deeper in the pipeline.

function bindInput<TIn, TOut>(
body: (input: VarRef<TIn>) => BodyResult<TOut>,
): TypedAction<TIn, TOut>

Sugar for bind([identity], ([input]) => pipe(drop, body(input))).

Postfix: No.


withResource({ create, action, dispose })​

RAII-style resource management. Creates a resource, merges it with the input, runs an action, then disposes.

function withResource<
TIn extends Record<string, unknown>,
TResource extends Record<string, unknown>,
TOut,
>(args: {
create: Pipeable<TIn, TResource>,
action: Pipeable<TResource & TIn, TOut>,
dispose: Pipeable<TResource, unknown>,
}): TypedAction<TIn, TOut>

Postfix: No.

withResource({
create: createWorktree,
action: doWork,
dispose: cleanupWorktree,
})

Data manipulation​

constant(value)​

Produce a fixed value, ignoring the pipeline input.

function constant<TValue>(value: TValue): TypedAction<any, TValue>

Postfix: No.

constant("hello")
// Input: anything β†’ Output: "hello"

identity()​

Pass input through unchanged.

function identity<TValue = any>(): TypedAction<TValue, TValue>

Postfix: No.


drop​

Discard the pipeline value. Produces never. A value, not a function.

const drop: TypedAction<any, never>

Postfix: Yes β€” .drop(). Prefer postfix β€” no pipe wrapper needed.

sideEffect.drop()

tag(kind)​

Wrap the input as a tagged union member: { kind, value: input }.

function tag<
TDef extends Record<string, unknown>,
TKind extends keyof TDef & string,
>(kind: TKind): TypedAction<TDef[TKind], TaggedUnion<TDef>>

Postfix: Yes β€” .tag(kind). Prefer postfix β€” it infers the full variant map from the preceding action's type context, avoiding the verbose tag<TDef, TKind> type parameters.

// Standalone requires explicit type parameters
tag<{ NeedsRefactor: FileInfo; Clean: FileInfo }, "NeedsRefactor">("NeedsRefactor")

// Postfix infers from context
analyzeFile.tag("NeedsRefactor")

flatten()​

Flatten a nested array one level.

function flatten<TElement>(): TypedAction<TElement[][], TElement[]>

Postfix: Yes β€” .flatten(). Prefer postfix β€” it infers the element type from the preceding action's nested array output.


extractPrefix()​

Split a namespaced kind string (e.g., "Option.Some") into a two-level structure for branchFamily dispatch. When the input has no kind field and is an array, produces { kind: "Array", value: input } as a fallback β€” this enables branchFamily to dispatch arrays alongside Option/Result/Iterator.

function extractPrefix(): TypedAction<
{ kind: string; value: unknown },
{ kind: string; value: { kind: string; value: unknown } }
>

Postfix: No. This is an internal builtin used by branchFamily β€” not typically called directly.


asOption()​

Convert a boolean to Option<void>. true produces Some(void), false produces None. Used internally by Iterator.filter() to convert boolean predicates into Option values for flatMap-based filtering.

function asOption(): TypedAction<boolean, Option<void>>

Postfix: Yes β€” .asOption(). Available when output is boolean.

isValid.asOption()
// true β†’ { kind: "Option.Some", value: null }
// false β†’ { kind: "Option.None", value: null }

getField(field) / .getField(field)​

Extract a single field from an object.

function getField<
TObj extends Record<string, unknown>,
TField extends keyof TObj & string,
>(field: TField): TypedAction<TObj, TObj[TField]>

Postfix: Yes β€” .getField(field). Prefer postfix β€” it infers the object type and valid field names from the preceding action's output.

getUserProfile.getField("email")

getIndex(index) / .getIndex(index)​

Extract a single element from a tuple by index.

function getIndex<TTuple extends unknown[], TIndex extends number>(
index: TIndex,
): TypedAction<TTuple, TTuple[TIndex]>

Postfix: Yes β€” .getIndex(index). Prefer postfix β€” it infers the tuple type and valid indices from the preceding action's output.

all(getUser, getSettings).getIndex(0)

pick(...keys)​

Select named fields from an object.

function pick<
TObj extends Record<string, unknown>,
TKeys extends (keyof TObj & string)[],
>(...keys: TKeys): TypedAction<TObj, Pick<TObj, TKeys[number]>>

Postfix: Yes β€” .pick(...keys). Prefer postfix β€” it infers the object type and valid keys from the preceding action's output.

getUserProfile.pick("name", "email")

range(start, end)​

Produce an integer array [start, start+1, ..., end-1]. Computed at AST build time (emits a constant node).

function range(start: number, end: number): TypedAction<any, number[]>

Postfix: No.


augment(action)​

Run an action, then merge its output back into the original input.

function augment<
TInput extends Record<string, unknown>,
TOutput extends Record<string, unknown>,
>(action: Pipeable<TInput, TOutput>): TypedAction<TInput, TInput & TOutput>

Postfix: Yes β€” .augment() (no arguments; wraps the preceding action). Prefer postfix β€” it infers the input/output intersection type from the preceding action.

computeHash.augment()
// Input: { file: string } β†’ Output: { file: string, hash: string }

wrapInField(field) / .wrapInField(field)​

Wrap the input as { <field>: <input> }.

function wrapInField<TField extends string, TValue>(
field: TField,
): TypedAction<TValue, Record<TField, TValue>>

Postfix: Yes β€” .wrapInField(field). Prefer postfix β€” it infers the value type from the preceding action's output.

computeHash.wrapInField("hash")
// Input: string β†’ Output: { hash: string }

splitFirst() / .splitFirst()​

Head/tail decomposition. Returns Some([first, rest]) for non-empty arrays, None for empty arrays.

function splitFirst<TElement>(): TypedAction<
TElement[],
Option<[TElement, TElement[]]>
>

Postfix: Yes β€” .splitFirst(). Prefer postfix β€” it infers the element type from the preceding action's array output.

listFiles.splitFirst().branch({
Some: processFirstFile,
None: handleEmpty,
})

splitLast() / .splitLast()​

Init/last decomposition. Returns Some([init, last]) for non-empty arrays, None for empty arrays.

function splitLast<TElement>(): TypedAction<
TElement[],
Option<[TElement[], TElement]>
>

Postfix: Yes β€” .splitLast(). Prefer postfix β€” it infers the element type from the preceding action's array output.


tap(action)​

Run an action for side effects, then pass the original input through unchanged.

function tap<TInput extends Record<string, unknown>>(
action: Pipeable<TInput, any>,
): TypedAction<TInput, TInput>

Postfix: No.

tap(logToFile)
// Input: T β†’ Output: T (logToFile runs but output is discarded)

Option<T>​

Option<T> is a tagged union: TaggedUnion<{ Some: T; None: void }>.

All combinators desugar to branch + builtins at the AST level.

CombinatorTypeDescription
Option.some()T β†’ Option<T>Wrap as Some
Option.none()void β†’ Option<T>Produce None
Option.map(action)Option<T> β†’ Option<U>Transform Some value
Option.andThen(action)Option<T> β†’ Option<U>Monadic bind (flatMap)
Option.unwrapOr(default)Option<T> β†’ TExtract Some or compute default
Option.flatten()Option<Option<T>> β†’ Option<T>Unwrap nested Option
Option.filter(predicate)Option<T> β†’ Option<T>Keep if predicate returns Some
Option.collect()Option<T>[] β†’ T[]Collect Some values, discard Nones
Option.isSome()Option<T> β†’ booleanTest for Some
Option.isNone()Option<T> β†’ booleanTest for None
Option.schema(valueSchema)β€”Build a Zod validator for Option<T>

Postfix: .mapOption(action) transforms the Some value of an Option output. Prefer postfix β€” it infers T from the preceding action's Option<T> output.

Zod schemas:

Option.schema(z.string())
// validates: { kind: "Some", value: "hello" } | { kind: "None", value: null }

Result<TValue, TError>​

Result<TValue, TError> is a tagged union: TaggedUnion<{ Ok: TValue; Err: TError }>.

All combinators desugar to branch + builtins at the AST level.

CombinatorTypeDescription
Result.ok()TValue β†’ Result<TValue, TError>Wrap as Ok
Result.err()TError β†’ Result<TValue, TError>Wrap as Err
Result.map(action)Result<V, E> β†’ Result<U, E>Transform Ok value
Result.mapErr(action)Result<V, E> β†’ Result<V, E2>Transform Err value
Result.andThen(action)Result<V, E> β†’ Result<U, E>Monadic bind on Ok
Result.or(fallback)Result<V, E> β†’ Result<V, E2>Fallback on Err
Result.and(other)Result<V, E> β†’ Result<U, E>Replace Ok with other
Result.unwrapOr(default)Result<V, E> β†’ VExtract Ok or compute default
Result.flatten()Result<Result<V, E>, E> β†’ Result<V, E>Unwrap nested Result
Result.toOption()Result<V, E> → Option<V>Ok→Some, Err→None
Result.toOptionErr()Result<V, E> → Option<E>Err→Some, Ok→None
Result.transpose()Result<Option<V>, E> β†’ Option<Result<V, E>>Swap Result/Option nesting
Result.isOk()Result<V, E> β†’ booleanTest for Ok
Result.isErr()Result<V, E> β†’ booleanTest for Err
Result.schema(okSchema, errSchema)β€”Build a Zod validator for Result<V, E>

Postfix: .mapErr(action) transforms the Err value; .unwrapOr(default) extracts Ok or applies default to Err. Prefer postfix β€” both infer TValue and TError from the preceding action's Result<TValue, TError> output.

Zod schemas:

Result.schema(z.string(), z.number())
// validates: { kind: "Ok", value: "hello" } | { kind: "Err", value: 42 }

Zod schema constructors​

taggedUnionSchema(cases)​

Build a Zod schema for any TaggedUnion<TDef>. Each key becomes a variant; the value schema validates the value field. Use z.null() for void variants.

function taggedUnionSchema<TDef extends Record<string, z.ZodTypeAny>>(
cases: TDef,
): z.ZodType<TaggedUnion<TDef>>
const classifySchema = taggedUnionSchema({
HasErrors: z.array(errorSchema),
Clean: z.null(),
});
// validates: { kind: "HasErrors", value: [...] } | { kind: "Clean", value: null }

For Option and Result, use the dedicated Option.schema() and Result.schema() methods instead β€” they provide tighter type inference.


Handler definition​

createHandler(definition, exportName?)​

Create a typed handler from an async function with optional Zod validators.

function createHandler<TValue, TOutput>(
definition: {
inputValidator?: z.ZodType<TValue>;
outputValidator?: z.ZodType<TOutput>;
handle: (context: { value: TValue }) => Promise<TOutput>;
},
exportName?: string,
): TypedAction<TValue, TOutput>

The returned action serializes to an Invoke node. At runtime, the Rust scheduler spawns a TypeScript worker subprocess that calls handle.

export const processFile = createHandler({
inputValidator: z.string(),
outputValidator: z.object({ status: z.string() }),
handle: async ({ value: filePath }) => {
// ...
return { status: "done" };
},
}, "processFile");

createHandlerWithConfig(definition, exportName?)​

Like createHandler, but also accepts step-level configuration.

function createHandlerWithConfig<TValue, TOutput, TStepConfig>(
definition: {
inputValidator?: z.ZodType<TValue>;
outputValidator?: z.ZodType<TOutput>;
stepConfigValidator?: z.ZodType<TStepConfig>;
handle: (context: { value: TValue; stepConfig: TStepConfig }) => Promise<TOutput>;
},
exportName?: string,
): TypedAction<TValue, TOutput>

Workflow execution​

runPipeline(pipeline, input?)​

Run a pipeline to completion. Optionally provide an input value.

async function runPipeline(
pipeline: Action,
input?: unknown,
): Promise<void>

This is the main entry point. It serializes the pipeline AST to JSON, resolves the Rust binary, and spawns barnum run --config <json>.

await runPipeline(
listFiles.iterate().map(processFile).collect().then(commit),
);

Postfix method summary​

These methods are available on any TypedAction via dot-chaining. Always prefer the postfix form β€” it infers all types from the preceding action's output, eliminating explicit type parameters.

MethodStandalone equivalentNotes
.then(next)chain(a, next)
.iterate()Iterator.fromArray() / Iterator.fromOption() / Iterator.fromResult()Requires array, Option, or Result output
.map(action)Iterator.map(action)Requires Iterator output (also works on Option, Result)
.flatMap(action)Iterator.flatMap(action)Requires Iterator output; action returns any IntoIterator
.filter(pred)Iterator.filter(pred)Requires Iterator output; pred returns boolean
.collect()Iterator.collect()Requires Iterator output; unwraps to T[]
.forEach(action)chain(a, forEach(action))Low-level; requires array output; prefer .iterate().map(action).collect()
.branch(cases)chain(a, branch(cases))Requires tagged union output
.drop()chain(a, drop)
.tag(kind)chain(a, tag(kind))
.flatten()chain(a, flatten())Requires nested array output
.getField(field)chain(a, getField(field))
.getIndex(index)chain(a, getIndex(index))Requires tuple output
.pick(...keys)chain(a, pick(...keys))
.wrapInField(field)chain(a, wrapInField(field))
.splitFirst()chain(a, splitFirst())Requires array output
.splitLast()chain(a, splitLast())Requires array output
.augment()augment(a)Merges output back into input
.asOption()asOption()Requires boolean output; converts to Option<void>
.mapOption(action)chain(a, Option.map(action))Requires Option output
.mapErr(action)chain(a, Result.mapErr(action))Requires Result output
.unwrapOr(default)chain(a, Result.unwrapOr(default))Requires Result output