references/static-typing-params.md
# Static Typing: Params
Part of the [static typing migration](static-typing.md). This page covers migrating params.
Typed params are an **essential part of this migration**, not an optional extra you can defer. The work is to replace the legacy global `params` object — readable from anywhere — with a typed `params {}` block plus **explicit propagation** down the call tree. Three steps:
1. **Categorize each param by where it's used.** Some params are referenced only in `nextflow.config` (profiles, process directives); others are read in `.nf` script code (workflows, processes). Config-only params stay in config — they are a config concern and are not type-checked the same way. The migration targets the **script-used** params.
The config `params {}` block and the script `params {}` block are **complementary sources of truth** — config params for the config file, script params for the script — so expect the two blocks to coexist rather than deduplicate them. `nextflow_schema.json` is a JSON-schema representation of the *combined* params; leave it in place (it still drives external tooling).
If the pipeline uses the `nf-schema` plugin (declared in `nextflow.config`), make sure it is updated to version 2.7.2 or later. Older versions have minor incompatibilities with static typing.
2. **Declare the script-used params in a typed `params {}` block** in the **entry-workflow file** (`main.nf`). No default = **required** (the run fails if omitted); `?` marks optional; a Boolean with no default defaults to `false`.
```nextflow
params {
input: Path // required
fasta: Path? // optional
aligner: String = 'bismark' // default
save_reference: Boolean // defaults to false
clip_r1: Integer = 0
}
```
3. **Propagate params as explicit inputs — do not read the global `params` object inside subworkflows or processes.** Instead, the entry workflow reads the typed params and threads them down through `take:` inputs. Each subworkflow/process receives exactly the params it needs as declared inputs.
To avoid one `take:` line per param, **bundle related params into a single record** and pass that one record down:
```nextflow
// one record for the param bundle a subworkflow needs
record AlignerParams {
aligner: String
save_reference: Boolean
clip_r1: Integer
}
workflow ALIGNER {
take:
// ...
aligner_params: AlignerParams
// ...
}
```
As long as this record is a strict subset of the `params` block, you can pass the `params` object as the record input — no need to construct a new record or pass each param separately.
references/static-typing-processes.md
# Static Typing: Processes
Part of the [static typing migration](static-typing.md). This page covers typing a **process**: its inputs, outputs, and `script`/`exec` body.
## Process inputs
The `input:` section becomes a list of declarations. There are three forms:
```nextflow
input:
meta: Map // scalar (name: Type)
reads: Path
record(meta: Map, reads: Path) // destructured record (binds each field directly)
tuple(meta: Map, reads: Path) // destructured tuple
```
| Untyped | ✅ Typed |
|---------|---------|
| `val meta` | `meta: Map` |
| `path reads` | `reads: Path` |
| `path "*.fq"` (collection) | `reads: List<Path>` (ordered) / `Bag<Path>` (unordered) / `Set<Path>` |
| Optional input | `name: Type?` (nullable via `?`) |
- The `path` qualifier becomes the `Path` **type**
- `val` qualifiers are replaced by a concrete type (`String`, `Integer`, `Float`, `Boolean`, `Map`, `List<T>`, …)
- `Channel` and `Value` are **not** valid input types — those are workflow-level only.
### Migrate tuple inputs to records
`tuple(...)` and `record(...)` are **distinct types** — a `tuple(meta: Map, reads: List<Path>)` input (typed `Tuple<...>`) will **not** accept a `record(...)` value (typed `Record`), and vice versa. So migrate the input from a tuple to a record:
```nextflow
// legacy tuple input
tuple val(meta), path(reads)
// typed tuple input
tuple(meta: Map, reads: List<Path>)
// ✅ migrated to a record input — connects to record channels
record(meta: Map, reads: List<Path>)
```
Both bind `meta` and `reads` the same way in the script body; only the type changes. Prefer the record form everywhere the channel carries records.
## Process input staging
Staging options that lived on the input qualifier move to a dedicated `stage:` section:
| Untyped | ✅ Typed |
|---------|---------|
| `path(fasta, stageAs: 'tmp/*')` | input `fasta: Path` + `stage: stageAs fasta, 'tmp/*'` |
| `env 'FOO'` | input `foo: String` + `stage: env 'FOO', foo` |
| `stdin` | input `message: String` + `stage: stdin message` |
## Process outputs
A typed process should emit a single **fat record** containing all outputs (the `meta` map, each named file) instead of many skinny tuples. Use `file()` / `files()` instead of the `path` qualifier.
| Untyped | ✅ Typed |
|---------|---------|
| `path "out.txt"` | `file('out.txt')` |
| `path "*.txt"` (collection) | `files('*.txt')` |
| `path "*.log", optional: true` | `file('*.log', optional: true)` |
| `stdout` | `stdout()` |
| `env 'FOO'` | `env('FOO')` |
Migrate multiple skinny tuples to a single fat record:
```nextflow
// before
tuple val(meta), path("*.bam"), emit: bam
tuple val(meta), path("*.bai"), emit: bai
// after
record(meta: meta, bam: file("*.bam"), bai: file("*.bai"))
```
## Versions and the `topic:` section
Outputs with a `topic:` qualifier become **topic emissions**. Move them to the `topic:` section and use `>>` to emit to the desired topic:
```nextflow
topic:
file("versions.yml") >> 'versions'
tuple(task.process, 'tool-name', eval('tool-version')) >> 'versions'
```
Collect them in the entry workflow with `channel.topic('versions')` instead of threading a `ch_versions` channel through every call. This also lets you delete the `ch_versions = ch_versions.mix(...)` plumbing.
`channel.topic('versions')` has an **unknown element type** — the type checker cannot infer it from the emissions, even though you know what each process emitted. Cast each element to the appropriate type when mapping over it.
```nextflow
// file emissions
channel.topic('versions').map { v -> v as Path }
// (process, tool, version) tuple emissions — cast to Tuple, then the map
// closure can destructure it directly
channel.topic('versions')
.map { v -> v as Tuple<String,String,String> }
.map { process, tool, version -> tuple(process.tokenize(':').last(), " ${tool}: ${version}") }
```
## The `when:` block
Typed processes drop the `when: task.ext.when` idiom.
## Standard library gotchas
The Nextflow standard library exposes a **restricted subset** of the underlying Groovy classes — many methods from Groovy are not supported under static typing. As a result, you may encounter `Unrecognized method`/`Unrecognized property` errors when migrating code that manipulates standard types such as lists, maps, and strings.
The following is a working list of common substitutions (from real migrations, not exhaustive). See the [standard types reference](https://docs.seqera.io/nextflow/reference/stdlib-types) for the comprehensive API.
| Legacy pattern | Use instead |
|----------------|-------------|
| `list.flatten()` | `list.collectMany { v -> v }` |
| `list.sort()` | `list.toSorted()` |
| `list.unique()` | `list.toUnique().toList()` |
| `map.clone(); map << [k: v]` | `map += [k: v]` |
| `map.putAll(other)` | `map + other` |
| `map.remove(k)` | `map.subMap(map.keySet() - [k])` |
| `string.split(sep)` | `string.tokenize(sep)` |
| `string.split(/regex/)` | `string.replaceAll(/…/, '…').tokenize(sep)` |
| `x.toString()` | `"${x}"` |
| `'"' + x + '"'` | `"\"${x}\""` |
| `task.memory.giga` / `.mega` | `task.memory.toGiga()` / `toMega()` |
Additional gotchas worth calling out:
- **`files("...")` returns a `Set<Path>` (unordered), not a `List`.** A `Set` has no `[]` indexing and no `.first()`. When order matters (read pairs `_1`/`_2`, or any `fastq[0]` access), use **`files("...").toSorted()`** to get an ordered `List<Path>`.
- **The `collect()` operator returns `Value<Bag>` (unordered), not a `List`.** If a process input (or other consumer) is declared `List<T>`, feeding it a `collect()` result is a type mismatch (`Bag` ≠ `List`). Either convert the bag to a list with `toSorted()`, declare the consumer as `Bag<T>`.
- **The `collect { }` and `findAll { }` iterable methods return an `Iterable`, not a `List`.** Convert with `toSorted()` (or `toList()` if you know the source is a list). For example, `def x = []` infers `List<E>`, so a later `x = coll.collect { }` fails (`Iterable` ≠ `List`) — use `coll.collect { }.toList()` instead.
## Implicit variables and `def`
In general, variables should be declared explicitly with `def`. However, in processes and workflows, variables can be declared without `def` to make them scoped to the entire definition. This is often useful (e.g. a bare variable set in `script:`/`exec:` is visible in the `output:` section as well as `template` files). Only add `def` when the compiler flags it as an error.
## Templates and false warnings
The compiler cannot see inside `template` files, so it may report false `declared but not used` warnings for variables used by the template. **Read the template file yourself** to determine which variables it uses, and make sure those variables are declared without `def` so that they are visible to the template.
references/static-typing-workflows.md
# Static Typing: Workflows
Part of the [static typing migration](static-typing.md). This page covers typing a **workflow**: its `take:`/`emit:` interface and the channel operators in its body.
## Workflow inputs and outputs
`take:` and `emit:` gain type annotations. Channels use `Channel<V>`; dataflow values use `Value<V>`; regular values just use `V`.
Inputs (`take:`)
| Untyped | ✅ Typed |
|---------|----------|
| `ch_samples` | `ch_samples: Channel<Sample>` |
| input file (from params) | `fasta: Path` |
| input file (from upstream process) | `val_fasta: Value<Path>` |
| optional input | `val_index: Value<Path?>` or `index: Path?` |
Outputs (`emit:`)
| Untyped | ✅ Typed |
|---------|----------|
| per-sample channel output | `results: Channel<MethylseqResult> = ch_results` |
| optional singleton output | `multiqc_report: Value<Path?> = val_report` |
Inside the body, join the per-sample result channels into one fat record channel with a single unified record type (see below).
## Record types
Record types can be defined and included across scripts. In practice, record types are only needed to define workflow input/output types:
```nextflow
workflow ALIGN {
take:
samples: Channel<Sample>
// ...
emit:
aligned: Channel<AlignedSample>
}
record Sample {
meta: Map
reads: List<Path>
}
record AlignedSample {
meta: Map
bam: Path
bai: Path
}
```
Use `record(field: value, ...)` to construct a record and `r + record(extra: v)` to add fields. Access fields by name (`sample.id`).
Records are **duck-typed**: a value satisfies a record type if it has at least the declared fields. The type checker will tell you if a call site has a record mismatch.
## Dataflow logic
Migrating dataflow logic to static typing consists of:
1. removing legacy syntax patterns (`set`/`tap`, `.out`, `|`/`&`)
2. replacing tuples with records in channels
3. replacing legacy operators with equivalent typed operators
The typed operators are `collect, combine, filter, flatMap, groupBy, join, map, mix, reduce, subscribe, unique, until, view`; all other operators are discouraged under static typing. The full guidelines are in the [operators tutorial](https://docs.seqera.io/nextflow/tutorials/static-types-operators).
### Trivial renames
| Avoid / changed | ✅ Use under typing |
|-----------------|---------------------|
| `Channel.of(...)` (capitalized) | `channel.of(...)` (lowercase) |
| `.set { x }` / `.tap { x }` | plain assignment: `x = ch` |
| `.join(other)` | `.join(other, by: 'id')` — `by` is required |
| `.mix(a, b, c)` | chain: `.mix(a).mix(b).mix(c)` |
| `.distinct()` | `.unique()` |
| implicit closure param `it` | explicit param: `{ r -> ... }` |
| `.collectFile(...)` | deferred — see [acceptable residual warnings](static-typing.md#acceptable-residual-warnings) |
### `.out` access — single vs. multi-output
```nextflow
// ❌ .out on the call name
FOO(ch)
FOO.out.bam
// ✅ multi-output: assign the call result, then access by name
out = FOO(ch)
out.bam
out.bai
// ✅ single output: the call IS the channel — no .out, no field access
bam = FOO(ch) // bam is the output channel directly
```
**Calling an untyped workflow:** the type checker can't infer an untyped workflow's emit shape, so named-emit access (`init.ids`) compiles clean but fails at runtime if the callee is single-emit (it returns the channel directly — use `init` alone).
### Pipe / fork (`|`, `&`)
```nextflow
// ❌ pipe and fork
ch | FOO | BAR
FOO & BAR
// ✅ explicit calls
BAR(FOO(ch))
FOO(ch) ; BAR(ch)
```
### `.branch { }` → one `.filter` per branch
```nextflow
// ❌ branch
ch.branch { r ->
aspera: r.method == 'aspera'
ftp: r.method == 'ftp'
}
// ✅ one filter per branch (add a .map if you were reshaping in the branch)
ch.filter { r -> r.method == 'aspera' }
ch.filter { r -> r.method == 'ftp' }
```
### `.multiMap { }` → pass records directly, or one `.map` per output
```nextflow
// ❌ multiMap
ch.multiMap { r ->
reads: r.reads
meta: r.meta
}
// ✅ one map per output (or just pass the record through and access fields downstream)
ch.map { r -> r.reads }
ch.map { r -> r.meta }
```
### `.groupTuple()` → `.groupBy()`
`groupBy` takes **no closure** — it takes a channel of `(key, value)` tuples, groups them by key, and emits `(key, values)` tuples. To fix the expected group size (as `groupTuple`'s `size:` did), feed `(key, size, value)` tuples instead.
```nextflow
// ❌ groupTuple
ch.groupTuple() // or groupTuple(by: 0)
// ✅ groupBy on a channel of (key, value) tuples, then destructure
ch.groupBy() // emits (key, values) tuples — no closure argument
.map { key, values -> record(id: key, files: values) }
```
### `.splitCsv()` operator → `.flatMap`
```nextflow
// ❌ splitCsv as a channel operator
ch.splitCsv(header: true)
// ✅ flatMap + the per-file splitCsv method
ch.flatMap { f -> f.splitCsv(header: true) }
```
`splitCsv` returns `List<?>` — the row type is ambiguous, so the type checker rejects field/element access until you cast each row:
- **with `header: true`** → cast each row to `Map<String,String>` (access by column name)
- **without a header** → cast each row to `List<String>` (access by position)
```nextflow
ch.flatMap { f -> f.splitCsv(header: true) }
.map { row -> row as Map<String,String> }
```
### `each` input qualifier → `.combine` in the caller
```nextflow
// ❌ each qualifier fans the process out over a list
process ALIGN {
input:
path(reads)
each aligner
}
ALIGN(ch_reads, ['bwa', 'bowtie2'])
// ✅ drop `each`; expand the combinations with combine() in the caller
process ALIGN {
input:
tuple(reads: Path, aligner: String)
}
aligners = channel.of('bwa', 'bowtie2')
ALIGN(ch_reads.combine(aligners))
```
## Tips & tricks
Here are some common patterns to use while navigating the type checker.
### Multiple channel inputs
Processes cannot be called with multiple channel inputs. Combine multiple inputs into a single source with `combine` instead.
When the inputs consist of a per-sample record channel and one or more dataflow values, you can use `combine` with named args to append the value to each sample record:
```nextflow
samples = channel.of( record(id: 1, fastq: file('1.fq')) )
index = channel.value( file('index.fa') )
ALIGN( samples.combine(strandedness: 'auto', index: index) )
```
You would also update `ALIGN` to declare a single combined record input.
### Conditional process outputs
A common pattern is to assign a channel to a process output, or an empty channel if the process is skipped:
```nextflow
ch_fastqc = channel.empty()
if (!params.skip_fastqc) {
ch_fastqc = FASTQC(...)
}
```
The type checker will complain about this because `ch_fastqc` will be typed as `Channel<?>` and any downstream operation (e.g. `ch_fastqc.map { r -> ... }`) will not be able to see the actual record fields from `FASTQC`. Assign the empty channel in an `else` instead:
```nextflow
if (!params.skip_fastqc) {
ch_fastqc = FASTQC(...)
}
else {
ch_fastqc = channel.empty()
}
```
### Skinny tuples vs fat records
A common workflow pattern is to call several processes on a single input channel and emit each result separately:
```nextflow
workflow BAM_STATS_SAMTOOLS {
take:
ch_samples // channel: [ val(meta), path(fastq) ]
main:
FOO(ch_samples)
BAR(ch_samples)
BAZ(ch_samples)
emit:
foo = FOO.out // channel: [ val(meta), path(foo) ]
bar = BAR.out // channel: [ val(meta), path(bar) ]
baz = BAZ.out // channel: [ val(meta), path(baz) ]
}
```
These channels are called **skinny tuples** because they contain thin vertical slices of related per-sample results.
With records it is better to join these channels into a single **fat record** channel:
```nextflow
workflow BAM_STATS_SAMTOOLS {
take:
ch_samples: Channel<Sample>
main:
ch_foo = FOO(ch_samples)
ch_bar = BAR(ch_samples)
ch_baz = BAZ(ch_samples)
ch_results = ch_foo
.join(ch_bar, by: 'meta')
.join(ch_baz, by: 'meta')
emit:
ch_results as Channel<FooBarBaz>
}
record Sample {
meta: Map
fastq: Path
}
record FooBarBaz {
meta: Map
foo: Path
bar: Path
baz: Path
}
```
references/static-typing.md
# Static Typing Migration
Nextflow 26.04 introduces **static typing**: type annotations on params, workflow inputs/outputs, and process inputs/outputs, plus **records** (named data structures that replace tuples). The goal of this migration is to add types and convert tuples to records **without changing pipeline behavior**, so the type checker can catch type errors before runtime.
Typing is **opt-in and backward-compatible** — you enable it per file with a feature flag, so the migration can proceed one script at a time.
Reference:
- https://docs.seqera.io/nextflow/process-typed
- https://docs.seqera.io/nextflow/workflow-typed
- https://docs.seqera.io/nextflow/reference/stdlib-types
- https://docs.seqera.io/nextflow/tutorials/static-types
- https://docs.seqera.io/nextflow/tutorials/static-types-operators
## Before you start
1. **Strict syntax must be clean first.** Typed code requires the strict (v2) syntax parser. If `nextflow lint -o concise .` reports any errors, do the [strict syntax migration](strict-syntax.md) before this one. Typing builds on top of it.
2. **This is a large, invasive migration.** Converting tuples to records reshapes channels, inputs, outputs, and the operators between them. Do it **incrementally**, one file at a time, enabling the feature flag and solving type errors per file, rather than flipping everything at once.
3. **Records are the point.** The payoff is replacing `tuple val(meta), path(...)` with records whose fields have names and types (`sample.id`, `sample.bam`).
## Type checking
Static typing is enabled per-script by enabling a feature flag at the top:
```nextflow
nextflow.enable.types = true
```
`nextflow lint` only does syntax checks — it will **not** report type errors. Type checking is provided by the Nextflow language server, which the plugin bundles as a script:
```bash
${CLAUDE_PLUGIN_ROOT}/scripts/nextflow-typecheck.sh <project-dir>
```
It runs the language server headlessly over the whole project and prints one line per diagnostic, with the path relative to the project root. Exit code is 1 if any errors were found. Run it after each round of edits — it is the only way to see type errors.
- It requires `jq` and Java 17+.
- Some type mismatches are reported as errors while others are reported as warnings — make sure to check both.
- The first run downloads the language server jar (~1 min); later runs reuse the cached jar. A full scan takes a few seconds to a minute depending on project size, so batch your edits rather than re-running it after every single change.
## The migration loop
For each file:
1. Add `nextflow.enable.types = true`.
2. Migrate the script definitions following the [reference pages](#reference-pages) below.
3. Replace tuples with records; define record types as needed.
4. Run `nextflow-typecheck.sh`, read the diagnostics, fix, and repeat.
Work **outward from the leaves**: type the processes first, then the subworkflows that call them, then the entry workflow and params. A typed process forces its callers to provide correctly-shaped records, so the errors guide you up the call tree.
Apply the **smallest behavior-preserving change** — same files staged, same values emitted. This is not a logic refactor.
Keep fixing the diagnostics until **no errors or type-mismatch warnings remain** — except for the [acceptable residual warnings](#acceptable-residual-warnings) below.
Finally, confirm behavior is unchanged by performing a test run:
```bash
# profile names and params may vary by pipeline
nextflow run . -profile test,docker --outdir results -resume
```
The output directory (`results`) must match a pre-migration run.
Typing can also **reveal latent bugs** — a mismatch may be a pre-existing bug the type checker exposed, not a regression you introduced. When a diff traces to a genuine semantic difference like this, **flag it for the user to review** rather than reshaping the code to hide it.
### Acceptable residual warnings
Some warnings are **out of scope for a static-typing migration** and may be left in place rather than chased — do not distort the code to silence them.
#### collectFile
`collectFile` is not a typed operator (see the [operator list](static-typing-workflows.md#dataflow-logic)), so it warns under typing. It is handled by the **[workflow-outputs migration](workflow-outputs.md)**, which comes after this one — leave it for now.
#### Process templates
The type checker cannot see inside `template` files, so it flags template-only variables as unused (see [Templates and false warnings](static-typing-processes.md#templates-and-false-warnings)). Confirm by reading the template, then leave the variable as a bare assignment.
## Reference pages
Load the page for what you're typing (working **outward from the leaves**, as above):
| Typing a… | Read | Covers |
|-----------|------|--------|
| **process** | [static-typing-processes.md](static-typing-processes.md) | inputs, staging, outputs (fat records), `topic:`/versions, stdlib gotchas |
| **workflow** | [static-typing-workflows.md](static-typing-workflows.md) | `take:`/`emit:` types, channel operator swaps |
| **params** | [static-typing-params.md](static-typing-params.md) | typed `params {}` block, propagation as inputs |
## Type casting
You can use the cast (`as`) operator to coerce the type of an expression to satisfy the type checker while migrating code. However, type casting should be avoided in the final code except where there is explicit guidance for it (`splitCsv`, topic channel values). Aside from these limited cases, you should never need to use type casts on a fully migrated pipeline.
## Critical rules for this migration
1. **STRICT SYNTAX FIRST** — Static typing requires the v2 parser. Run `nextflow lint -o concise .` and resolve all strict-syntax errors (see [strict-syntax.md](strict-syntax.md)) before adding any types.
2. **MIGRATE INCREMENTALLY** — Enable `nextflow.enable.types = true` per file, type the leaf modules first, then work up through subworkflows to the entry workflow. Fix type errors as you go.
3. **MIGRATE TUPLES TO RECORDS** — Convert `tuple val(meta), path(...)` to records with named, typed fields. Use explicit record types as needed at component boundaries. Access fields by name, never by index.
4. **MIGRATE LEGACY OPERATORS** — Replace `set`/`tap`, `.out`, `|`/`&`, `branch`, `multiMap`, `groupTuple`, operator-form `splitCsv`, and capitalized `Channel.` factories per the [operator guidelines](static-typing-workflows.md#dataflow-logic).
5. **MIGRATE PARAMS** — Move script-used params into a typed `params {}` block alongside the entry workflow and propagate them as explicit inputs; do not use the global `params` object inside subworkflows/processes.
references/strict-syntax.md
# Strict Syntax Migration
Nextflow 26.04 makes the **strict syntax parser** the default. Code that parsed under the legacy parser may now be rejected or flagged. The goal of this migration is to make the pipeline parse cleanly under strict syntax **without changing its behavior**.
Reference:
- https://docs.seqera.io/nextflow/reference/syntax
- https://docs.seqera.io/nextflow/strict-syntax
## The loop: detect → fix → verify
### Step 1: Detect
Run the linter over the project. It parses every `.nf` script and `.config` file and reports errors and warnings:
```bash
nextflow lint -o concise .
```
- `-o concise` is best for triage (one line per issue). Use `-o full` to see the offending code in context, or `-o json` to process programmatically.
- Lint specific paths instead of the whole project: `nextflow lint main.nf workflows/ subworkflows/`.
- `.git`, `.nextflow`, `.nf-test`, `work`, etc. are excluded by default; add more with `-exclude`.
### Step 2: Fix
Work through the reported issues using the [reference table](#reference-strict-syntax-fixes) below. For each one:
1. Read the file and locate the flagged line.
2. Apply the **smallest behavior-preserving change** that resolves it.
3. Do not refactor unrelated code, rename things gratuitously, or "improve" logic — this migration is about parser compatibility only.
### Step 3: Verify
Re-run `nextflow lint -o concise .` after each batch of fixes and repeat until there are **zero errors**.
Finally, confirm behavior is unchanged. Prefer the project's own test suite:
```bash
nf-test test # if the pipeline uses nf-test
nextflow run . -profile test,docker --outdir results -resume # otherwise, a test profile run
```
## Reference: strict syntax fixes
These are the common errors the strict parser raises and their behavior-preserving fixes.
### Removed — must be rewritten
| Pattern | ❌ Not allowed | ✅ Fix |
|---------|---------------|--------|
| `import` statements | `import groovy.json.JsonSlurper` | Use the fully qualified name inline: `new groovy.json.JsonSlurper()` |
| Top-level statements mixed with declarations | bare statements beside `process`/`workflow` defs | Move statements into the entry `workflow { }` |
| Top-level workflow handlers | `workflow.onComplete { ... }` at script level | Assign inside the entry workflow: `workflow { workflow.onComplete = { ... } }` |
| Assignment in an expression | `hello(x = 1)`, `f(x++)` | Assign first: `x = 1; hello(x)` / `x += 1; f(x)` |
| `for` / `while` loops | `for (x in list) { ... }` | Higher-order functions: `list.each { x -> ... }`, `.collect { }`, `.find { }` |
| `switch` statements | `switch (v) { case 'a': ... }` | `if`/`else if`/`else` chain |
| Spread operator | `[meta, *bambai]` | Enumerate: `[meta, bambai[0], bambai[1]]`, or destructure: `def (a, b) = list` |
| Implicit env vars | `"PWD = ${PWD}"` | `"PWD = ${env('PWD')}"` (or `System.getenv('PWD')`) |
| Closure variable called like a function | `def func = { ... }` inside process/workflow, called as `func(x)` | Promote to a top-level `def func(x) { ... }` function |
### Restricted — limited forms only
| Pattern | ❌ Not allowed | ✅ Fix |
|---------|---------------|--------|
| `addParams` / `params` in includes | `include { f } from './m' addParams(x: 1)` | Pass values as explicit workflow/process inputs |
| Typed / multi / `final` var declarations | `String s = 'x'`, `def a = 1, b = 2`, `final n = 1` | `def s = 'x'`; one `def` per variable; (typed `def s: String = 'x'` allowed in 25.10+) |
| Interpolated slashy / dollar-slashy strings | `/${id}\.bam/`, `$/.../$` | Double-quoted: `"${id}\\.bam"`, or triple-quoted `""" ... """` |
| Soft casts | `(Map) x` | Hard cast `x as Map`, or a method like `'42'.toInteger()` |
| Unquoted process `env` in/out | `env FOO` | `env 'FOO'` |
| Missing `script:` label | input section but no `script:` | Add the `script:` label when other sections are present |
### Deprecated — warnings, fix while you are here
Always fix the following deprecation warnings. Other warnings don't need to be fixed unless it is convenient or the user asks.
| Pattern | ❌ Avoid | ✅ Prefer |
|---------|---------|-----------|
| Capitalized channel factory | `Channel.of(...)` | `channel.of(...)` (lowercase namespace) |
| Implicit closure parameter | `ch.map { it * 2 }` | `ch.map { v -> v * 2 }` |
| Process `shell:` section | `shell:` with `!{var}` | `script:` section with `${var}` |
### Config files
| Pattern | ❌ Not allowed | ✅ Fix |
|---------|---------------|--------|
| `if` statements / function defs at top level | `if (params.x) { process { ... } }` | Use a ternary on the setting (`containerOptions = params.use_spark ? '' : null`), or per-process selectors — the strict parser validates selectors against conditional processes, so the guard is usually unnecessary |
| Conditional `includeConfig` | `if (c) includeConfig 'a.config'` | Dynamic include with a closure: `includeConfig ({ c ? 'a.config' : 'b.config' }())` |
| Referencing non-`params` config settings as variables | `subnetwork = "regions/${google.location}/.."` | Route through `params`: set `params.location` and reference that |
## Gotchas from real migrations
- **A closure variable cannot share a name with a variable in the workflow definition.** The strict parser treats this as a shadowing conflict. Rename the closure parameter (e.g. `reads` → `reads_`) rather than the channel.
- **Most config `if` statements can simply be deleted, not rewritten.** Because the strict parser validates process selectors even for conditionally-included processes, the protective `if` wrapper around a process-selector config block is usually redundant.
- **CLI params are no longer auto-cast.** With the strict parser, `--flag false` arrives as the string `'false'` (which is truthy). Convert explicitly (`params.flag.toBoolean()`) or declare a typed `params` block. Watch for this when behavior changes after migration even though parsing succeeds.
## Escape hatch: `lib/` directory
If a piece of Groovy genuinely cannot be expressed in strict syntax (complex classes, third-party library use), move it into the project's **`lib/` directory**, where full Groovy is still allowed, and call it from the pipeline.
Reach for this only after confirming the construct can't be rewritten with the table above — most code can.
## Critical rules for this migration
1. **USE THE ESCAPE HATCH SPARINGLY** — Move code to `lib/` only when it truly cannot be expressed in strict syntax.
2. **WATCH FOR SILENT BEHAVIOR CHANGES** — CLI params are no longer auto-cast; verify boolean/numeric params still behave correctly after migration.
references/topic-channels.md
# Topic Channels Migration
A **topic channel** is a channel that any process can send outputs to, and that any part of the pipeline can consume with `channel.topic(name)` — no wiring in between. The main use case is **tool versions**: instead of threading a `ch_versions` channel through every workflow and mixing in `X.out.versions` after every call, each process sends its tool versions to a `versions` topic and the entry workflow reads them via `channel.topic('versions')`.
The topic carries a **tuple per tool**, not a file:
```nextflow
tuple val("${task.process}"), val('samtools'), eval("samtools version | sed '1!d;s/.* //'"), topic: versions
```
Each value arriving on the topic is `[ process, tool, version ]`. That shape drives everything below — most importantly, nf-core's `softwareVersionsToYAML` does **not** work on it (see Step 2.2).
## The migration loop
### Step 1: Detect
Find the versions plumbing:
```bash
grep -rn "emit: versions\|topic: versions\|ch_versions\|out.versions" --include='*.nf' .
```
Record, for each hit, which of these it is:
- a **legacy version output** (`path "versions.yml", emit: versions`) — becomes a topic emission
- an **already-migrated version output** (`..., topic: versions`) — leave alone
- **plumbing** (`ch_versions = channel.empty()`, `ch_versions.mix(...)`, `versions` in a workflow `take:`/`emit:` section, `ch_versions` passed as a call argument) — gets deleted
- a **consumer** (usually `softwareVersionsToYAML(ch_versions)` in the entry workflow) — switches to `channel.topic('versions')`
The pipeline may already have some modules on `topic: versions` while the rest still write `versions.yml`. When both schemes coexist there is usually **reconciliation scaffolding** holding them together. That scaffolding exists *only* because the migration is incomplete: it is plumbing, and it gets deleted too.
### Step 2: Fix
1. **Send process outputs to the topic.** Move the version command out of the `script:` heredoc and into an `eval()` in the `output:` section:
```nextflow
// before
output:
path "versions.yml", emit: versions
script:
"""
...
cat <<-END_VERSIONS > versions.yml
"${task.process}":
bedtools: \$(bedtools --version | sed -e "s/bedtools v//g")
END_VERSIONS
"""
// after
output:
tuple val("${task.process}"), val('bedtools'), eval('bedtools --version | sed -e "s/bedtools v//g"'), topic: versions
script:
"""
...
"""
```
Delete the heredoc from `script:` and `stub:` (if present), and each branch of a multi-branch script. One tuple per tool: a process reporting two tools emits two lines.
**The `eval()` string must not contain `$(...)` or a bare `$`.** Rewrite it:
```nextflow
// breaks — command substitution, and a `$` anchor in the sed expression
eval('echo $(qualimap 2>&1) | sed "s/^.*QualiMap v.//; s/Built.*$//"')
// works — pipe directly, drop the `$` anchor
eval('qualimap 2>&1 | grep -m1 "QualiMap v" | sed "s/^.*QualiMap v.//"')
```
The `echo $(...)` idiom exists in the heredocs to flatten multi-line output onto one line; replace it with `head -1`, `grep -m1`, or `sed -n '...p'`. Use a single-quoted string when the command contains double quotes (and vice versa) so nothing interpolates.
2. **Consume the topic** where the versions channel was consumed:
```nextflow
// before
softwareVersionsToYAML(ch_versions)
.collectFile(storeDir: "${params.outdir}/pipeline_info", name: '..._versions.yml', sort: true, newLine: true)
// after
channel.topic('versions')
.unique()
.map { process, tool, version -> [process.tokenize(':')[-1], "${tool}: ${version}"] }
.groupTuple()
.map { process, tools -> "${process}:\n ${tools.sort().join('\n ')}" }
.mix(channel.of(workflowVersionToYAML()))
.collectFile(storeDir: "${params.outdir}/pipeline_info", name: '..._versions.yml', sort: true, newLine: true)
```
3. **Delete the plumbing** — every `ch_versions` declaration, every `.mix(X.out.versions)`, every `versions` entry in a `take:`/`emit:` section, the corresponding argument at each call site, and any reconciliation scaffolding:
```nextflow
// before
workflow ALIGN {
take:
reads
ch_versions
main:
BWAMETH_ALIGN(reads)
ch_versions = ch_versions.mix(BWAMETH_ALIGN.out.versions)
emit:
bam = BWAMETH_ALIGN.out.bam
versions = ch_versions
}
// after
workflow ALIGN {
take:
reads
main:
BWAMETH_ALIGN(reads)
emit:
bam = BWAMETH_ALIGN.out.bam
}
```
### Step 3: Verify
Run `nextflow lint -o concise .` to check for mismatches. The deleted `take:`/`emit:` entries must be removed from every call site. Search for and remove any dangling `*SUB*.out.versions` references.
Then run the pipeline and compare the collated versions file against a pre-migration run:
```bash
nextflow run . -profile test,docker --outdir results -resume
```
It should list the same tools; ordering may differ, and duplicate lines legitimately disappear if the pre-migration consumer used `.distinct()`. Diff the whole output tree, not just the versions file — the only expected differences are the versions file itself, any `versions.yml` that was being published as a side effect of a `publishDir`, and any separate topic-versions file you deleted.
**nf-test snapshots will need regenerating.** Pipeline-level tests typically snapshot the collated versions file (`removeNextflowVersion("$outputDir/pipeline_info/..._versions.yml")`), and `versions.yml` disappears from published outputs. Expect every snapshot that touches versions to mismatch, and update them once you have confirmed the diff is only what you intended:
```bash
nf-test test --update-snapshot
```
## Gotchas
- **A process that consumes a topic must not send anything to it — the pipeline will hang forever.** For example, `MULTIQC` typically takes the collated versions file as input, so its own version output must stay a regular `emit:` or be dropped, not `topic: versions`.
- **Use `.unique()`, not `.distinct()`.** `distinct()` only collapses *consecutive* duplicates, and topic values from per-sample tasks arrive interleaved — so a process that ran three times appears three times in the output.
- **One tuple per tool means duplicate YAML keys.** A process reporting two tools emits two values, which naively become two `PROCESS:` blocks. `groupTuple()` by process name before formatting.
- **Topic order is nondeterministic.** Values arrive as tasks complete; use `sort: true` on `collectFile` if the output must be stable.
- **`emit:` and `topic:` can coexist** on the same output. Keep the `emit:` only if something still consumes `.out.versions` directly.
- **Only convert processes whose versions actually reach the consumer.** If a process's versions were deliberately never mixed in, sending them to the topic changes behavior.
## Critical rules for this migration
1. **NEVER SEND THE CONSUMER'S OWN VERSIONS TO THE TOPIC** — any process that consumes `channel.topic('versions')` output (directly or indirectly) must not emit to that topic, or the pipeline deadlocks.
2. **NEVER PUT `$(...)` OR A BARE `$` IN AN `eval()`** — Nextflow runs it inside a double-quoted `bash -c`, so the outer shell expands it first and the task fails with exit 127. Pipe directly instead of `echo $(...)`, and drop `$` anchors from sed expressions.
3. **DELETE ALL THE PLUMBING** — a half-migrated pipeline that still threads `ch_versions` around while also using the topic will double-report versions.
references/workflow-outputs.md
# Workflow Outputs Migration
Nextflow's **workflow output definition** replaces the legacy `publishDir` directive. Instead of each process deciding where to publish files, the entry workflow publishes *channels* through a `publish:` section, and a top-level `output {}` block declares where to publish the files in each channel. The goal of this migration is to move every `publishDir` into a single `output {}` block **without changing which files are published or where they end up**.
Stable since Nextflow 25.10; this skill assumes **26.04 or later**.
Reference:
- https://docs.seqera.io/nextflow/workflow#outputs
- https://docs.seqera.io/nextflow/tutorials/workflow-outputs
## Before you start: skinny tuples vs fat records
Many pipelines model intermediate data as channels of **skinny tuples** — tuples of the form `(meta, file)`, one tuple channel for each output file. For large pipelines, this pattern leads to many intermediate channels. This is problematic for the `output` block because every channel must be propagated to the entry workflow in order to be published. It can be done, but the end result will be extremely verbose, with many more emits than before.
The `output` block works best with channels of **fat records** — a single record channel containing all per-sample output files. Each workflow joins the outputs from different processes and emits one record channel rather than many tuple channels. This pattern makes it *significantly* easier to migrate from `publishDir` to the `output` block.
Before migrating a pipeline to workflow outputs, assess the impact:
- **Any pipeline using fat records:** proceed.
- **Small pipeline using skinny tuples:** proceed.
- **Large pipeline using skinny tuples:** **STOP** and recommend the [tuples → records migration](static-typing.md) first.
## The migration loop
### Step 1: Detect
Inventory the current publishing behavior:
- `publishDir` settings in config
- `publishDir` directives in process definitions
- `collectFile` operators that publish via `storeDir:`
For each match, record the following:
- **Which process** is targeted. Each `publishDir` can target one or more processes based on how it is declared in the config. A catch-all `publishDir` targets all processes that are not captured by a more specific `publishDir` (e.g. a `withName` selector).
- **Which files** are published. By default, `publishDir` publishes all output files declared by the process. It may use the `pattern:` option to publish specific outputs. It may use `enabled:` to toggle the entire declaration based on some condition.
- **Where** files are published. Each `publishDir` specifies a target directory path. It may use a `saveAs:` closure to specify per-file mappings.
### Step 2: Fix
Migrate one published output at a time:
1. **Propagate process outputs** up the call tree (via `emit:`) to the entry workflow so that they can be published.
2. **Add a `publish:` section** to the entry workflow, assigning each output a name: `samples = ch_samples`.
3. **Add a top-level `output {}` block** with a matching entry and `path` directive for each published channel (see the table below).
4. **Delete the `publishDir` directives** you replaced. Delete surrounding config files when they are left empty.
Define global publishing behavior once in config:
```groovy
outputDir = params.outdir // keeps the existing --outdir CLI option working
workflow.output.mode = params.publish_dir_mode
```
Override settings like `mode` in the `output` block as needed:
```nextflow
output {
samples {
path '...'
mode 'symlink'
}
}
```
Apply the **smallest behavior-preserving change** — same files, same destination paths, same conditions. This migration is not a refactor of pipeline logic.
### Step 3: Verify
Run `nextflow lint -o concise .` after the migration to make sure there are no errors.
The pipeline should produce the **same output tree** as before. Run the test profile both before and after and compare:
```bash
nextflow run . -profile test,docker --outdir results -resume
```
Compare the published directory structure against a pre-migration run. Every file should appear in the same relative location.
Results may differ due to different embedded output paths, timestamps, etc. These differences are acceptable as long as the results are semantically equivalent.
## Reference: publishDir → output block
### Basic publishing
Migrate a basic `publishDir` as follows:
```nextflow
// before
process FOO {
publishDir "${params.outdir}/foo", mode: 'copy'
input:
// ...
output:
tuple val(meta), path('...')
// ...
}
// after
workflow {
main:
ch_samples = FOO(/* ... */)
publish:
samples = ch_samples
}
output {
samples {
// params.outdir root moves to outputDir
path 'foo'
// mode moves to workflow.output.mode, override here only if needed
// mode 'copy'
}
}
```
You don't need to manually extract or flatten files from a channel — just emit and publish it directly. Nextflow automatically extracts files from data structures (lists, maps, records, tuples).
### Publishing per-sample
When the publish path depends on a per-sample value, use a dynamic `path` closure:
```nextflow
// before
publishDir "${params.outdir}/foo/${meta.id}"
// after
output {
samples {
path { sample -> "foo/${sample.id}" }
}
}
```
### Publishing per-file
When a `publishDir` sends individual files to different places via `pattern:` or `saveAs:`, use the `>>` operator inside the `path` closure:
```nextflow
// before
publishDir '...', pattern: '...'
publishDir '...', saveAs: { fn -> /* ... */ }
// after
output {
samples {
path { sample ->
sample.fastq_1 >> 'fastq/'
sample.fastq_2 >> 'fastq/'
sample.bam >> 'align/'
}
}
}
```
Only files routed with `>>` are published when using this form. If the publish target ends with a slash, the source files are published *into* it; otherwise, the source file is published *as* the target name.
### Conditional publishing
When a `publishDir` conditionally publishes files via `enabled:`, use the `enabled` directive or gate inside the `path` closure:
```nextflow
// before
publishDir 'align', enabled: params.save_bams
// after (alt 1)
output {
samples {
path 'align'
enabled params.save_bams
}
}
// after (alt 2)
output {
samples {
path { sample ->
// ...
sample.bam >> (params.save_bams ? "align/" : null)
}
}
}
```
If a `publishDir` specifies `enabled: false`, it is a no-op — delete it.
### Index files
The `index` directive writes a CSV/JSON/YAML catalog of the channel's values (with metadata preserved):
```nextflow
output {
samples {
path { /* ... */ }
index {
path 'samplesheet.csv'
header true
}
}
}
```
It can replace a `collectFile` operation or a hand-rolled "create a samplesheet" process. However, such a refactor might not be trivial — flag any opportunities for `index` refactoring as follow-up work.
## Critical rules for this migration
1. **CHECK RECORDS FIRST** — If the pipeline is large and still uses tuples (`tuple val(meta), path(...)`) rather than records, STOP and recommend the [tuples → records migration](static-typing.md) first. Workflow outputs are much easier with fat records.
2. **INVENTORY BEFORE EDITING** — Find every `publishDir` (scripts and config) and record what/where/condition for each before changing anything.
3. **VERIFY BY DIFFING** — Compare the published directory tree before and after; it must be identical. Run the project's tests to confirm.
SKILL.md
---
name: migrate-nextflow-code
description: Migrate Nextflow pipeline code to newer language requirements. Use when fixing strict syntax errors, replacing versions channels with topic channels, migrating from `publishDir` to workflow outputs (the `output {}` block), or adding static typing (typed processes/workflows, records, typed params).
allowed-tools: Bash, Read, Edit, Write, Glob, Grep
---
# Migrate Nextflow Code
Migrate Nextflow pipeline code to satisfy newer language requirements. Each migration is detection-driven: a tool reports what must change, you apply behavior-preserving fixes, then re-run the tool until it is clean.
**Requires Nextflow 26.04 or later** (for the `nextflow lint` command and the strict syntax parser, which is the default from 26.04 onward).
**nf-core pipelines — check the template version before starting.** If the pipeline has a `.nf-core.yml`, check which version of nf-core/tools last generated its template. If `nf_core_version` is unspecified or less than 3.0.0, **stop**. Tell the user to upgrade their template first (`nf-core pipelines sync`) before attempting any code migrations. This will resolve syntax errors in the template code and provide a cleaner baseline.
## How to use this skill
This SKILL.md is an **index**. Identify which migration the user needs from the table below, then **read the matching reference file** for the full detect → fix → verify procedure before doing any work. Each reference file is self-contained.
| Migration | Use when the user… | Read this file |
|-----------|--------------------|----------------|
| **Strict syntax** | …has strict syntax errors, or asks to run `nextflow lint` to find and fix errors | [`references/strict-syntax.md`](references/strict-syntax.md) |
| **Topic channels** | …wants to replace the `ch_versions` plumbing (or another channel threaded through every workflow) with a topic channel | [`references/topic-channels.md`](references/topic-channels.md) |
| **Static typing** | …wants to add static types — typed process/workflow inputs and outputs, records (replacing tuples), or typed params | [`references/static-typing.md`](references/static-typing.md) |
| **Workflow outputs** | …wants to replace `publishDir` directives with workflow outputs — a top-level `output {}` block and a `publish:` section in the entry workflow | [`references/workflow-outputs.md`](references/workflow-outputs.md) |
If the request matches no row, tell the user which migrations are currently supported rather than improvising.
If the request covers multiple migrations, recommend performing only the first matching migration in the table. The order is also a dependency order: strict syntax -> topic channels -> static typing -> workflow outputs. Do not try to perform multiple migrations at the same time.
## Critical Rules
These hold regardless of which reference file you load. Each reference file adds its own migration-specific rules.
1. **Detect before editing** — follow the reference guidelines to determine what needs changing. Never guess.
2. **Preserve behavior** — a migration adapts code to new language requirements; it is not a refactor. Apply the smallest change that resolves each issue and leave unrelated logic alone.
3. **Verify** — run the project's tests (`nf-test test`, or `nextflow run . -profile test,docker -resume`) to confirm behavior is unchanged before declaring the migration done.