Skip to main content
Version: main

TypeScript AST

Barnum's TypeScript library is a DSL that produces a serializable AST. listFiles.iterate().map(refactor).collect() builds a JSON tree describing the workflow structure. The Rust runtime receives this tree and executes it.

The DSL maintains three properties:

  1. Type safety β€” pipe(a, b) only compiles if a's output matches b's input
  2. Clean serialization β€” JSON.stringify() produces a minimal tree with no type metadata
  3. Composability β€” every combinator returns the same TypedAction type, so they nest freely

The nine action types​

The AST is a closed algebra of nine node types:

type Action =
| InvokeAction // Leaf: call a handler
| ChainAction // Sequential: run first, pipe output to rest
| AllAction // Fan-out: same input to all children, collect as tuple
| ForEachAction // Map: apply action to each array element
| BranchAction // Dispatch: route on { kind, value } discriminant
| ResumeHandleAction // Resume-style effect handler
| ResumePerformAction // Raise a resume-style effect
| RestartHandleAction // Restart-style effect handler
| RestartPerformAction; // Raise a restart-style effect

Invoke is the only leaf β€” it calls either a TypeScript handler (subprocess) or a builtin (inline data transform like Identity, Drop, Tag, Merge, GetField). Every other node is structural: it composes children into larger workflows.

Phantom types​

Enforcing In β†’ Out type matching across pipeline steps without polluting the serialized JSON requires phantom types β€” type-level fields that exist for the TypeScript compiler but are never set at runtime.

type TypedAction<In, Out> = Action & {
__phantom_in?: (input: In) => void; // contravariant
__phantom_out?: () => Out; // covariant
__phantom_out_check?: (output: Out) => void; // contravariant
__in?: In; // covariant
};

These four fields enforce invariance on both In and Out:

  • Input invariance: __phantom_in (contravariant) + __in (covariant) together mean In must match exactly. A handler expecting { name: string } won't accept { name: string; age: number } or string.
  • Output invariance: __phantom_out (covariant) + __phantom_out_check (contravariant) together mean Out must match exactly.

Data crosses a serialization boundary to handlers that may run in Rust, Python, or any future language. Structural subtyping (TypeScript's default) would let extra fields through β€” fields the receiving handler doesn't know about. Invariance catches this at compile time.

Why phantom fields need to be optional​

Phantom fields use ?: (optional) because they're never assigned. At runtime, a TypedAction is just a plain Action object β€” the phantom fields are undefined. The ?: makes TypeScript treat them as present-but-optional rather than erroring on their absence.

Non-enumerable methods​

TypedAction also has methods (.then(), .iterate(), .map(), .branch(), .drop(), etc.) attached via Object.defineProperties as non-enumerable:

function typedAction(action: Action): TypedAction {
Object.defineProperties(action, {
then: { value: thenMethod, configurable: true },
iterate: { value: iterateMethod, configurable: true },
map: { value: mapMethod, configurable: true },
branch: { value: branchMethod, configurable: true },
// ...
});
return action;
}

Non-enumerable means invisible to JSON.stringify(). The serialized AST contains only the structural Action fields β€” no methods, no phantom types, no handler implementations.

How combinators build trees​

Every combinator returns a TypedAction. They compose by nesting:

pipe​

pipe(a, b, c)
// Produces: Chain(a, Chain(b, c))
// Type: TypedAction<InOfA, OutOfC>

pipe right-folds its arguments into nested Chain nodes. The TypeScript overloads (up to 12 arguments) enforce that each step's output matches the next step's input.

Iterator (user-facing API)​

The Iterator pattern is the preferred way to work with collections. It wraps the ForEach AST node in a higher-level interface:

listFiles.iterate().map(refactor).collect()
// Produces: Chain(listFiles, Chain(BranchFamily→wrap, Chain(GetField("value"), Chain(ForEach(refactor), Tag("Iterator","Iterator")))))
// ...then Chain(GetField("value"))

.iterate() wraps the array as Iterator<T> via branchFamily dispatch. .map(f) unwraps, applies ForEach(f), and re-wraps. .collect() unwraps the Iterator back to T[]. The underlying ForEach node is still the execution primitive β€” Iterator provides the ergonomic layer.

forEach (low-level primitive)​

forEach(action)
// Produces: { kind: "ForEach", action }
// Type: TypedAction<In[], Out[]>

ForEach is the fundamental AST node for element-wise parallel operations. It applies the inner action to each array element and collects results. Iterator's .map(), .flatMap(), and .filter() all compile down to ForEach internally. The standalone forEach(action) combinator is available but Iterator methods are preferred for user-facing code.

all​

all(a, b, c)
// Produces: { kind: "All", actions: [a, b, c] }
// Type: TypedAction<In, [OutA, OutB, OutC]>

All children receive the same input and run concurrently. The output is a tuple of their results.

branch​

branch({ Ok: handleOk, Err: handleErr })
// Produces: { kind: "Branch", cases: { Ok: Chain(GetField("value"), handleOk), ... } }
// Type: TypedAction<TaggedUnion<{ Ok: TOk; Err: TErr }>, OutOk | OutErr>

Branch dispatches on the kind field of a tagged union. Each case handler receives the unwrapped value β€” the GetField("value") is inserted automatically. This auto-unwrapping means case handlers work with payloads directly, not the full { kind, value } wrapper.

loop, tryCatch, earlyReturn​

These desugar into RestartHandle + RestartPerform + Branch. See algebraic effect handlers for the compilation.

Tagged unions​

Barnum uses a { kind, value } convention for discriminated unions:

type TaggedUnion<TDef extends Record<string, unknown>> = {
[K in keyof TDef & string]: {
kind: K;
value: TDef[K];
__def?: TDef; // phantom: carries the full variant map
};
}[keyof TDef & string];

__def carries the full variant map ({ Ok: string; Err: number }) as a phantom field, so .branch() can decompose the union via keyof ExtractDef<Out> instead of conditional types. Never set at runtime.

Standard library types build on this:

type Option<T> = TaggedUnion<{ Some: T; None: void }>;
type Result<TValue, TError> = TaggedUnion<{ Ok: TValue; Err: TError }>;

The PipeIn escape hatch​

Handlers that ignore their input (like constant(42)) have input type never. But never is the bottom type β€” nothing is assignable to it, so pipe(something, constant(42)) would fail.

The fix:

type PipeIn<T> = [T] extends [never] ? any : T;

When In is never, PipeIn widens it to any, letting the action sit anywhere in a pipeline. The [T] extends [never] syntax (tuple form) prevents TypeScript from distributing over union members.

CaseHandler: relaxed variance for branch cases​

Branch case handlers use a separate type with contravariant-only input and covariant-only output:

type CaseHandler<TIn, TOut> = Action & {
__phantom_in?: (input: TIn) => void; // contravariant only
__phantom_out?: () => TOut; // covariant only
};

This is intentionally less strict than TypedAction's invariance:

  • Contravariant input: A handler accepting unknown (like drop) can handle any variant payload. (input: unknown) => void is assignable to (input: SpecificType) => void.
  • Covariant output: Branch case outputs are inferred from the actual handlers, not constrained. This lets TypedAction<TError, never> (a throw token) be assignable to CaseHandler<TError, TValue>.

createHandler: from Zod to AST​

createHandler bridges the runtime world (Zod validators, async functions) and the AST world (serializable JSON):

const handler = createHandler({
inputValidator: z.object({ file: z.string() }),
outputValidator: z.string(),
handle: async ({ value }) => { /* ... */ },
}, "myHandler");

Internally:

  1. Detect caller file via V8's Error.prepareStackTrace β€” the handler's module path is captured from the call stack, not passed explicitly.
  2. Compile Zod β†’ JSON Schema via zodToCheckedJsonSchema() (see validation).
  3. Build AST node: { kind: "Invoke", handler: { kind: "TypeScript", module, func, input_schema, output_schema } }.
  4. Attach non-enumerable metadata: __definition (the Zod validators and handle function) and HANDLER_BRAND are set as non-enumerable properties. The worker subprocess reads __definition to find and execute the handler. JSON.stringify never sees them.

Serialization example​

const workflow = listFiles
.iterate()
.map(refactor.then(typeCheck).then(fix))
.collect()
.drop();

The Iterator methods compile down to the same fundamental AST nodes. .iterate() wraps via BranchFamily dispatch, .map(f) uses ForEach, and .collect() uses GetField("value"). The serialized AST contains ForEach nodes β€” Iterator is purely a TypeScript-level abstraction:

{
"kind": "Chain",
"first": {
"kind": "Chain",
"first": { "kind": "Invoke", "handler": { "kind": "TypeScript", "module": "./steps.ts", "func": "listFiles" } },
"rest": {
"kind": "Chain",
"first": { "kind": "Invoke", "handler": { "kind": "Builtin", "builtin": { "kind": "Tag", "prefix": "Iterator", "kind_": "Iterator" } } },
"rest": {
"kind": "Chain",
"first": { "kind": "Invoke", "handler": { "kind": "Builtin", "builtin": { "kind": "GetField", "field": "value" } } },
"rest": {
"kind": "Chain",
"first": {
"kind": "ForEach",
"action": {
"kind": "Chain",
"first": { "kind": "Invoke", "handler": { "kind": "TypeScript", "module": "./steps.ts", "func": "refactor" } },
"rest": {
"kind": "Chain",
"first": { "kind": "Invoke", "handler": { "kind": "TypeScript", "module": "./steps.ts", "func": "typeCheck" } },
"rest": { "kind": "Invoke", "handler": { "kind": "TypeScript", "module": "./steps.ts", "func": "fix" } }
}
}
},
"rest": { "kind": "Invoke", "handler": { "kind": "Builtin", "builtin": { "kind": "Tag", "prefix": "Iterator", "kind_": "Iterator" } } }
}
}
}
},
"rest": { "kind": "Invoke", "handler": { "kind": "Builtin", "builtin": { "kind": "Drop" } } }
}

No types, no methods, no phantom fields. Just structure. The Rust runtime deserializes this into Action variants via serde and executes it. Note that Iterator methods compile away entirely β€” the AST contains only ForEach, GetField, Tag, and other fundamental nodes.