Skip to main content
Version: 0.3

Postfix Methods

Every TypedAction has 13 postfix methods (.then(), .forEach(), .branch(), etc.) that enable a fluent chaining API. These methods exist at the type level for the TypeScript compiler and as non-enumerable properties at runtime — invisible to JSON.stringify().

The interesting part is how TypeScript's this parameter interacts with phantom types. Postfix methods use this in three distinct ways: as AST context (building Chain(this, rest) nodes), as a type constraint (gating availability based on the output type), and as a type source (reading In from the phantom fields to produce intersection types).

Attachment: non-enumerable, shared closures

typedAction() attaches all 13 methods via Object.defineProperties:

export function typedAction<In, Out, Refs extends string = never>(
action: Action,
): TypedAction<In, Out, Refs> {
if (!("then" in action)) {
Object.defineProperties(action, {
then: { value: thenMethod, configurable: true },
forEach: { value: forEachMethod, configurable: true },
branch: { value: branchMethod, configurable: true },
// ... 10 more
});
}
return action as TypedAction<In, Out, Refs>;
}

Properties are non-enumerable by default when created via Object.defineProperties. This means:

  • JSON.stringify() skips them — the serialized AST is clean JSON.
  • toEqual() in tests ignores them — two actions with the same structure are equal regardless of methods.
  • "then" in action detects whether methods are already attached, preventing double-attachment.

The method implementations are module-level functions, not closures created per instance. Every TypedAction shares the same thenMethod, forEachMethod, etc. The this binding is provided by the call site.

this as AST context

The simplest use of this is building Chain(this, rest) nodes. Most postfix methods follow this pattern:

function thenMethod(this: TypedAction, next: Action): TypedAction {
return typedAction({ kind: "Chain", first: this, rest: next as Action });
}

function dropMethod(this: TypedAction): TypedAction {
return typedAction({
kind: "Chain",
first: this,
rest: { kind: "Invoke", handler: { kind: "Builtin", builtin: { kind: "Drop" } } },
});
}

a.then(b) constructs Chain(a, b). a.drop() constructs Chain(a, Drop). The this parameter gives the method access to the Action object it was called on — the first half of the chain.

At the type level, In is preserved from this and Out comes from the chained action:

then<TNext>(next: Pipeable<Out, TNext>): TypedAction<In, TNext, Refs>

In flows through unchanged. Out becomes TNext. The method chains the type parameters exactly as chain(a, b) would.

this as type constraint

Three methods use TypeScript's this parameter to restrict when the method is callable:

forEach — requires array output

forEach<TIn, TElement, TNext, TRefs extends string>(
this: TypedAction<TIn, TElement[], TRefs>,
action: Pipeable<TElement, TNext>,
): TypedAction<TIn, TNext[], TRefs>

The this: TypedAction<TIn, TElement[], TRefs> constraint means .forEach() only compiles when the output type is an array. TypeScript infers TElement from the array element type:

listFiles                        // TypedAction<void, string[]>
.forEach(processFile) // ✓ — TElement = string, Out = string[]
.forEach(processFile) // ✗ — Out is ProcessResult[], not ProcessResult[][]

The constraint also re-binds In as TIn. The this parameter's TIn replaces the outer In — TypeScript unifies them during overload resolution, extracting the phantom type from the concrete instance.

mapOption — requires Option output

mapOption<TIn, T, U, TRefs extends string>(
this: TypedAction<TIn, Option<T>, TRefs>,
action: Pipeable<T, U>,
): TypedAction<TIn, Option<U>, TRefs>

Only callable when Out is Option<T>. TypeScript unifies the phantom __phantom_out with () => Option<T> to infer T, making it available as the action's input type.

mapErr and unwrapOr — requires Result output

mapErr<TIn, TValue, TError, TErrorOut>(
this: TypedAction<TIn, Result<TValue, TError>, any>,
action: Pipeable<TError, TErrorOut>,
): TypedAction<TIn, Result<TValue, TErrorOut>, Refs>
unwrapOr<TIn, TValue, TError>(
this: TypedAction<TIn, Result<TValue, TError>, any>,
defaultAction: CaseHandler<TError, TValue>,
): TypedAction<TIn, TValue, Refs>

Both constrain Out to Result<TValue, TError> and extract TValue and TError from the phantom fields.

The Refs position uses any instead of a type parameter. Without this, when Refs = never (the common case), TypeScript falls back to the constraint bound string, which breaks unification. Using any suppresses that fallback. The return type reads Refs from the enclosing TypedAction type directly (not from the this constraint), preserving the original refs tracking.

this as type source: .augment()

.augment() is the method that most directly exploits phantom type access through this:

// Type signature
augment(): TypedAction<In, In & Out, Refs>

The return type is In & Out — an intersection of the action's input and output. This requires knowing In, which is only available from the TypedAction's phantom fields.

The implementation constructs All(this, Identity) → Merge:

function augmentMethod(this: TypedAction): TypedAction {
return typedAction({
kind: "Chain",
first: {
kind: "All",
actions: [this as Action, { kind: "Invoke", handler: { kind: "Builtin", builtin: { kind: "Identity" } } }],
},
rest: { kind: "Invoke", handler: { kind: "Builtin", builtin: { kind: "Merge" } } },
});
}

At runtime: All runs the sub-pipeline and Identity in parallel on the same input. Identity passes the input through unchanged. Merge combines [Out, In] into In & Out.

The standalone augment(action) function in builtins.ts does the same thing at the AST level, but the postfix form is more natural in pipelines:

// Standalone: augment wraps the action
augment(pipe(pick("file"), computeHash))

// Postfix: augment follows the sub-pipeline
pick("file").then(computeHash).augment()

Both produce identical AST nodes.

CaseHandler: covariant output for throw tokens

unwrapOr accepts CaseHandler<TError, TValue> rather than Pipeable<TError, TValue> for its defaultAction:

type CaseHandler<TIn, TOut, TRefs extends string = never> = Action & {
__phantom_in?: (input: TIn) => void; // contravariant
__phantom_out?: () => TOut; // covariant only — no __phantom_out_check
};

CaseHandler omits the contravariant __phantom_out_check that TypedAction has. This makes the output covariant only instead of invariant:

// With invariant output (TypedAction):
// TypedAction<string, never> is NOT assignable to TypedAction<string, number>
// because (output: number) => void is not assignable to (output: never) => void

// With covariant output (CaseHandler):
// CaseHandler<string, never> IS assignable to CaseHandler<string, number>
// because () => never is assignable to () => number

This matters for throw tokens. throwError from tryCatch has type TypedAction<TError, never> — it fires an effect and never returns. With covariant output, never (the bottom type) is assignable to any TValue, so throw tokens work as unwrapOr defaults:

tryCatch(
(throwError) => pipe(
riskyStep.unwrapOr(throwError), // throwError: TypedAction<Error, never>
// CaseHandler<Error, string> ← ✓ never <: string
),
recovery,
)

Type transformations

Each postfix method transforms the type signature in a specific way:

MethodInOutNotes
.then(b)preservedbecomes b's output
.forEach(b)preservedTElement[]TNext[]this constraint extracts element type
.branch(cases)preservedunion of case outputs
.drop()preservednever
.tag(kind)preservedTaggedUnion<TDef>
.get(field)preservedOut[TField]
.pick(...keys)preservedPick<Out, TKeys>
.flatten()preservedT[][]T[]conditional type
.merge()preservedMergeTuple<Out>
.augment()preservedIn & Outreads In from phantom fields
.mapOption(b)re-bound via thisOption<T>Option<U>this constraint extracts T
.mapErr(b)re-bound via thisResult<V, E>Result<V, E2>this constraint extracts V, E
.unwrapOr(b)re-bound via thisResult<V, E>Vcovariant output via CaseHandler

Every method preserves In (or re-binds it identically via this constraints). Out is always transformed. Refs is always preserved.