Closure-Based Channel Operators
nf-bids extends Nextflow with three closure-driven operators — groupTupleBy,
joinBy, and combineBy — that work with maps and arbitrary Groovy objects instead of
requiring fixed-position tuples.
Why closure-based operators?
Nextflow’s built-in groupTuple, join, and combine operators require tuple index
keys:
// Built-in: tuple structure required, index is opaque
channel.of(['sub-01', 'file.nii'], ['sub-01', 'other.nii'])
.groupTuple(by: 0)
With nf-bids flat output, items are maps — not tuples. Closure-based operators read any field without restructuring:
// Closure-based: works with maps, self-documenting
channel.of([subject: 'sub-01', file: 'file.nii'],
[subject: 'sub-01', file: 'other.nii'])
.groupTupleBy { it.subject }
groupTupleBy
Signature
channel.groupTupleBy(Closure keyExtractor)
channel.groupTupleBy(Closure keyExtractor, Map opts)
Options
| Option | Type | Description |
|---|---|---|
|
|
Emit a group as soon as it reaches this many items (streaming groups). |
|
|
|
|
|
When |
Examples
Channel
.of([subject: 'sub-01', file: 'a.nii'],
[subject: 'sub-01', file: 'b.nii'],
[subject: 'sub-02', file: 'c.nii'])
.groupTupleBy { it.subject }
// → ['sub-01', [[subject:'sub-01', file:'a.nii'],
// [subject:'sub-01', file:'b.nii']]]
// ['sub-02', [[subject:'sub-02', file:'c.nii']]]
channel.groupTupleBy { [it.subject, it.session] }
// Closure returns a List → wrapped automatically in CompositeKey
// → [[sub-01, ses-01], [ [item1, item2] ]]
channel.groupTupleBy({ it.subject }, [sort: { it.run }])
channel.groupTupleBy({ it.batch }, [size: 3, remainder: false])
joinBy
Signature
left.joinBy(DataflowReadChannel right, Closure keyExtractor)
left.joinBy(DataflowReadChannel right, Closure keyExtractor, Map opts)
left.joinBy(DataflowReadChannel right, Closure leftKey, Closure rightKey)
left.joinBy(DataflowReadChannel right, Closure leftKey, Closure rightKey, Map opts)
When a single keyExtractor is supplied it is applied to items from both channels.
Each join closure is a key extractor: nf-bids calls it with one item from its
respective channel and matches the returned keys.
Options
| Option | Type | Description |
|---|---|---|
|
|
When |
Examples
anatomical = Channel.of([subject: 'sub-01', t1: 't1.nii'],
[subject: 'sub-02', t1: 't1.nii'])
functional = Channel.of([subject: 'sub-01', bold: 'bold.nii'],
[subject: 'sub-02', bold: 'bold.nii'])
anatomical.joinBy(functional) { it.subject }
// → [subject:'sub-01', t1:'t1.nii', bold:'bold.nii']
// [subject:'sub-02', ...]
scans.joinBy(metadata,
{ it.scan_id }, // left key
{ it.participant_id }) // right key
left.joinBy(right, { it.id }, [remainder: true])
// Unmatched left items stay as [id:'C', val:3]
// Unmatched right items stay as [id:'D', data:'z']
combineBy
Signature
left.combineBy(DataflowReadChannel right, Closure keyExtractor)
left.combineBy(DataflowReadChannel right, Closure keyExtractor, Map opts)
left.combineBy(DataflowReadChannel right, Closure leftKey, Closure rightKey)
left.combineBy(DataflowReadChannel right, Closure leftKey, Closure rightKey, Map opts)
Like joinBy, the closures passed to combineBy are key extractors invoked with one
item at a time from the left or right channel.
Output
Emits fused combined items — the full cartesian product of matching items within each key group. No separate key tuple is emitted.
Examples
subjects = Channel.of([id: 'sub-01', age: 25], [id: 'sub-02', age: 30])
sessions = Channel.of([id: 'sub-01', session: 'ses-01'],
[id: 'sub-01', session: 'ses-02'],
[id: 'sub-02', session: 'ses-01'])
subjects.combineBy(sessions, { it.id })
// → [id:'sub-01', age:25, session:'ses-01']
// [id:'sub-01', age:25, session:'ses-02']
// [id:'sub-02', age:30, session:'ses-01']
subjects
.combineBy(sessions, { it.id })
.filter { fused -> fused.age >= 18 }
CompositeKey and KeyExtractor
When a closure returns a List, KeyExtractor.extractKey() wraps it in
CompositeKey — an @EqualsAndHashCode-annotated class — before it is used as a
HashMap key. This ensures [sub-01, ses-01] == [sub-01, ses-01] even across
different List instances.
channel.groupTupleBy { [it.subject, it.session] }
// Returns List → becomes CompositeKey([sub-01, ses-01]) internally
KeyExtractor.validateKeyExtractor(closure, operatorName) checks that the closure
accepts at least one parameter. Zero-parameter closures raise
IllegalArgumentException at operator construction time.
Performance notes
-
groupTupleBybuffers all items in memory until the channel closes (orsizeis reached); for large datasets usesizeto stream groups. -
joinByperforms cartesian product for duplicate keys; 1:N joins are fine, M:N should be avoided in hot paths. -
combineByalways produces a full cartesian product; guard with.filter()to reduce downstream load.
See Closure Operators Migration Guide for side-by-side comparisons with built-in operators.