---
title: Migrating to static typing
description: Migrate Nextflow pipelines to typed processes, typed workflows, and records.
---
# Migrating to static typing
Nextflow 26.10 fully supports [static typing][static-typing]. This tutorial shows how to migrate to static typing, using the [rnaseq-nf](https://github.com/nextflow-io/rnaseq-nf) pipeline as an example.
:::note
Static typing is optional. All existing code will continue to work.
:::
## Overview
Use static typing to model and validate the structure of your data as it flows through your pipeline. It consists of several new language features:
- **Type annotations** can be added to inputs and outputs at every level of a pipeline, from pipeline parameters to process inputs and outputs, using the [standard Nextflow types][stdlib-types]. These annotations make your code easier to understand, and Nextflow uses them to identify type-related errors at compile time.
- **Records** are a new data structure for modeling composite data. They are an alternative to tuples -- whereas tuple elements are accessed by index, record fields are accessed by name. With records, you can model data with meaningful names instead of tracking the order of tuple elements.
- **Record types** are custom type definitions that guarantee a minimum set of requirements for a record in a particular context. Records are *duck-typed*. A record can be used as an input as long as it meets the minimum requirements of that input, as defined by a record type.
Static typing also enables [pipeline composition][pipeline-composition]. When a pipeline uses typed parameters and workflow outputs, you can include the entire pipeline in another pipeline and call it like any other workflow.
## Developer tooling
### Type checking
When you use static typing, Nextflow can check your code for type-related errors. For example, it can validate that a channel of records has all the required fields when you pass it as input to a process.
Nextflow type-checks every script that enables the `nextflow.enable.types` feature flag. Use `nextflow lint` or the [Nextflow language server][vscode] to type-check your code during development.
### Automatic migration
The [Nextflow agent skills](https://github.com/nextflow-io/agent-skills) include a `migrate-nextflow-code` skill. Use it with a coding agent such as Claude Code, Codex, or Cursor to migrate a pipeline to static typing. See the repository README for installation instructions.
## Example: rnaseq-nf
This section demonstrates how to migrate a pipeline to static typing using [rnaseq-nf](https://github.com/nextflow-io/rnaseq-nf/tree/v2.4) (v2.4) as an example. See [Getting started with rnaseq-nf][rnaseq-nf-page] for an introduction to the pipeline.
The approach is as follows:
1. Convert legacy parameters to a `params` block
2. Convert the primary input (`params.reads`) from a glob pattern to a samplesheet
3. Convert each process to static typing
4. Convert each workflow to static typing
The completed migration is available in the [static-typing](https://github.com/nextflow-io/rnaseq-nf/tree/static-typing) branch.
### Migrating pipeline parameters
The pipeline defines the following parameters in the main script using the legacy syntax:
```nextflow
params.reads = "$baseDir/data/ggal/ggal_gut_{1,2}.fq"
params.transcriptome = "$baseDir/data/ggal/ggal_1_48850000_49020000.Ggal71.500bpflank.fa"
params.outdir = "results"
params.multiqc = "$baseDir/multiqc"
```
The pipeline also has a `nextflow_schema.json` schema with the following properties:
```json
"reads": {
"type": "string",
"description": "The input read-pair files",
"default": "${projectDir}/data/ggal/ggal_gut_{1,2}.fq"
},
"transcriptome": {
"type": "string",
"format": "file-path",
"description": "The input transcriptome file",
"default": "${projectDir}/data/ggal/ggal_1_48850000_49020000.Ggal71.500bpflank.fa"
},
"outdir": {
"type": "string",
"format": "directory-path",
"description": "The output directory where the results will be saved",
"default": "results"
},
"multiqc": {
"type": "string",
"format": "directory-path",
"description": "Directory containing the configuration for MultiQC",
"default": "${projectDir}/multiqc"
}
```
To migrate the pipeline parameters, use the schema and legacy parameters to define the equivalent `params` block:
```nextflow
params {
// The input read-pair files
reads: String
// The input transcriptome file
transcriptome: Path
// The output directory where the results will be saved
outdir: String = 'results'
// Directory containing the configuration for MultiQC
multiqc: Path = "${projectDir}/multiqc"
}
```
Specify the test data defaults in the config file:
```groovy
params.reads = "${projectDir}/data/ggal/ggal_gut_{1,2}.fq"
params.transcriptome = "${projectDir}/data/ggal/ggal_1_48850000_49020000.Ggal71.500bpflank.fa"
```
The config file should declare parameters that are only used by the config. It can also specify overrides for script parameters, ideally in a config profile (e.g., `test`). Since rnaseq-nf is a toy example, it uses the test data by default.
See [Typed parameters][typed-parameters-page] and [Config parameters][config-params] for more information.
### Loading a samplesheet input
The rnaseq-nf pipeline takes a glob pattern of FASTQ pairs (e.g., `data/ggal/ggal_gut_{1,2}.fq`) and uses the `channel.fromFilePairs()` factory to load the files as a channel of tuples:
```nextflow
read_pairs_ch = channel.fromFilePairs(params.reads, checkIfExists: true, flat: true)
```
Each tuple has three elements -- the sample ID (inferred from the file names) and the two FASTQ files.
This approach will not work with static typing because `fromFilePairs()` does not have a well-defined return type. A more robust way to model a collection of samples is with a *samplesheet*, such as a CSV file specifying samples as rows and sample fields as columns.
Create the following samplesheet to represent the test data:
**`data/allreads.csv`**
```
id,fastq_1,fastq_2
ggal_gut,https://raw.githubusercontent.com/nextflow-io/rnaseq-nf/refs/heads/master/data/ggal/ggal_gut_1.fq,https://raw.githubusercontent.com/nextflow-io/rnaseq-nf/refs/heads/master/data/ggal/ggal_gut_2.fq
ggal_liver,https://raw.githubusercontent.com/nextflow-io/rnaseq-nf/refs/heads/master/data/ggal/ggal_liver_1.fq,https://raw.githubusercontent.com/nextflow-io/rnaseq-nf/refs/heads/master/data/ggal/ggal_liver_2.fq
ggal_lung,https://raw.githubusercontent.com/nextflow-io/rnaseq-nf/refs/heads/master/data/ggal/ggal_lung_1.fq,https://raw.githubusercontent.com/nextflow-io/rnaseq-nf/refs/heads/master/data/ggal/ggal_lung_2.fq
ggal_spleen,https://raw.githubusercontent.com/nextflow-io/rnaseq-nf/refs/heads/master/data/ggal/ggal_spleen_1.fq,https://raw.githubusercontent.com/nextflow-io/rnaseq-nf/refs/heads/master/data/ggal/ggal_spleen_2.fq
```
Refactor `params.reads` to refer to the samplesheet file path instead of a glob pattern:
```nextflow
params {
// The input samplesheet of paired-end reads
reads: Path
// ...
}
```
```groovy
params.reads = "${projectDir}/data/allreads.csv"
params.transcriptome = // ...
```
Refactor the `read_pairs_ch` to load the samplesheet as a channel of records:
```nextflow
read_pairs_ch = channel.of(params.reads)
.flatMap { csv -> csv.splitCsv(header: true) }
.map { row ->
record(id: row.id, fastq_1: file(row.fastq_1), fastq_2: file(row.fastq_2))
}
```
A samplesheet input can be modeled as a channel of records instead of a file path. This approach simplifies the pipeline code and supports [pipeline composition][pipeline-composition], where a calling pipeline supplies the input from dataflow logic instead of a file.
Refactor `params.reads` as a channel of records:
```nextflow
params {
// The input samplesheet of paired-end reads
reads: Channel
// ...
}
record Sample {
id: String
fastq_1: Path
fastq_2: Path
}
```
In this example, `Sample` is a *record type* that matches the samplesheet structure. When you supply a file path to a channel-type parameter (e.g., `Channel`), Nextflow loads the file and validates and converts each row against the element type.
Because Nextflow loads the samplesheet automatically, `params.reads` replaces `read_pairs_ch`.
:::note
Channel params can also be loaded from JSON and YAML samplesheets. See [Typed parameters][typed-parameters-page] for more information.
:::
### Migrating processes
See [Typed processes][process-typed-page] for an overview of typed processes.
:::note
You must enable the `nextflow.enable.types` feature flag in each script that uses typed processes.
:::
FASTQC
The `FASTQC` process is defined as follows:
```nextflow
process FASTQC {
tag id
conda 'bioconda::fastqc=0.12.1'
input:
tuple val(id), path(fastq_1), path(fastq_2)
output:
path "fastqc_${id}_logs"
script:
"""
fastqc.sh "${id}" "${fastq_1} ${fastq_2}"
"""
}
```
To migrate the `FASTQC` process, rewrite the inputs and outputs as follows:
```nextflow
nextflow.enable.types = true
process FASTQC {
tag id
conda 'bioconda::fastqc=0.12.1'
input:
record(
id: String,
fastq_1: Path,
fastq_2: Path
)
output:
record(
id: id,
fastqc: file("fastqc_${id}_logs")
)
script:
"""
fastqc.sh "${id}" "${fastq_1} ${fastq_2}"
"""
}
```
The tuple input is converted to a record input using the `record()` destructor. The field types are specified alongside the field names. The `path` input qualifier is replaced by the `Path` type.
Whereas tuple elements must be specified in a particular order, record fields can be specified in any order. The records supplied by the calling workflow must have the same field names and types as the process definition.
The tuple output is converted to a record using the `record()` function and specifying a name for each record field. The `path` output qualifier is replaced by the `file()` function (or `files()` if multiple files are expected). See [process outputs][process-reference-typed] for the list of special functions that can be used in the `output:` section to retrieve task outputs.
QUANT
The `QUANT` process is defined as follows:
```nextflow
process QUANT {
tag id
conda 'bioconda::salmon=1.10.3'
input:
tuple val(id), path(fastq_1), path(fastq_2)
path index
output:
path "quant_${id}"
script:
"""
salmon quant \
--threads ${task.cpus} \
--libType=U \
-i ${index} \
-1 ${fastq_1} \
-2 ${fastq_2} \
-o quant_${id}
"""
}
```
To migrate the `QUANT` process, rewrite the inputs and outputs as follows:
```nextflow
nextflow.enable.types = true
process QUANT {
tag id
conda 'bioconda::salmon=1.10.3'
input:
record(
id: String,
fastq_1: Path,
fastq_2: Path
)
index: Path
output:
record(
id: id,
quant: file("quant_${id}")
)
script:
"""
salmon quant \
--threads ${task.cpus} \
--libType=U \
-i ${index} \
-1 ${fastq_1} \
-2 ${fastq_2} \
-o quant_${id}
"""
}
```
MULTIQC
The `MULTIQC` process is defined as follows:
```nextflow
process MULTIQC {
conda 'bioconda::multiqc=1.27.1'
input:
path '*'
path config
output:
path 'multiqc_report.html'
script:
"""
cp ${config}/* .
echo "custom_logo: \$PWD/nextflow_logo.png" >> multiqc_config.yaml
multiqc -n multiqc_report.html .
"""
}
```
To migrate this process, rewrite the inputs and outputs as follows:
```nextflow
nextflow.enable.types = true
process MULTIQC {
// ...
input:
logs: Bag
config: Path
// stage:
// stageAs logs, '*'
output:
file('multiqc_report.html')
// ...
}
```
In a typed process, file patterns for `path` inputs must be declared using a *stage directive*. In this example, the first input uses the variable name `logs`, and the `stageAs` directive stages the input using the glob pattern `*`.
In this case, you can omit the stage directive because `*` matches Nextflow's default staging behavior. Inputs of type `Path` or a `Path` collection (e.g., `Bag`) are staged by default using the pattern `'*'`.
:::note
In a legacy process, you can use the `arity` option to specify whether a `path` qualifier expects a single file or collection of files. When using typed inputs and outputs, the type determines this behavior, i.e., `Path` vs `Bag`.
:::
INDEX
Apply the same migration principles from the previous processes to migrate `INDEX`.
### Migrating workflows
Once you migrate every process called by a workflow to static typing, you can migrate the workflow itself.
See [Typed workflows][workflow-typed-page] for an overview of typed workflows.
:::note
You must enable the `nextflow.enable.types` feature flag in each script that uses typed workflows.
:::
RNASEQ
The `RNASEQ` workflow is defined as follows:
```nextflow
workflow RNASEQ {
take:
read_pairs_ch
transcriptome
main:
index = INDEX(transcriptome)
fastqc_ch = FASTQC(read_pairs_ch)
quant_ch = QUANT(read_pairs_ch, index)
emit:
fastqc = fastqc_ch
quant = quant_ch
}
```
You can infer the type of each workflow input by examining how the workflow is called. In this case, `RNASEQ` is called by the entry workflow with the following arguments:
```nextflow
workflow {
RNASEQ(params.reads, params.transcriptome)
// ...
}
```
You can determine the type of each input as follows:
- The parameter `params.reads` is declared in the `params` block as `Channel`.
- The parameter `params.transcriptome` is declared in the `params` block as `Path`.
Specify the workflow input types as follows:
```nextflow
nextflow.enable.types = true
workflow RNASEQ {
take:
read_pairs_ch: Channel
transcriptome: Path
// ...
}
```
The `read_pairs_ch` channel also needs to provide all of the record fields required by downstream processes. It is used by `FASTQC` and `QUANT`, which both declare the following record input:
```nextflow
input:
record(
id: String,
fastq_1: Path,
fastq_2: Path
)
```
The `Sample` record type contains all of the required fields.
:::note
In this case, the records in `read_pairs_ch` are identical to the record inputs of `FASTQC` and `QUANT`. However, `read_pairs_ch` would still be compatible if it contained additional record fields, as long as it contains the fields required by the two processes.
:::
The `FASTQC` and `QUANT` processes produce the channels `fastqc_ch` and `quant_ch`, both of which have type `Channel`:
- `fastqc_ch` contains records with the fields `id` and `fastqc`
- `quant_ch` contains records with the fields `id` and `quant`
You can infer this type information from the respective process outputs, as shown in the previous section.
These channels are emitted as the outputs of `RNASEQ`. However, with records it is usually simpler to join related channels into a single channel (e.g., to publish the channel as a [workflow output][migrating-workflow-outputs]).
Use the `join` operator to join `fastqc_ch` and `quant_ch` by sample ID:
```nextflow
nextflow.enable.types = true
workflow RNASEQ {
take:
read_pairs_ch: Channel
transcriptome: Path
main:
index = INDEX(transcriptome)
fastqc_ch = FASTQC(read_pairs_ch)
quant_ch = QUANT(read_pairs_ch, index)
samples_ch = fastqc_ch.join(quant_ch, by: 'id')
// ...
}
```
Finally, update the workflow to emit only the `samples_ch` channel. Type annotations are not required for emits, but they document the output and act as a sanity check. If the declared output type doesn't match the type of the assigned value, Nextflow reports an error.
While `samples_ch` could be emitted as type `Channel`, the best practice is to use an explicit record type so that downstream workflows know which record fields are available.
Define a new record type based on the available fields in `samples_ch`:
```nextflow
record AlignedSample {
id: String
fastqc: Path
quant: Path
}
```
Update the workflow to emit `samples_ch` with the new record type:
```nextflow
nextflow.enable.types = true
workflow RNASEQ {
take:
read_pairs_ch: Channel
transcriptome: Path
main:
index = INDEX(transcriptome)
fastqc_ch = FASTQC(read_pairs_ch)
quant_ch = QUANT(read_pairs_ch, index)
samples_ch = fastqc_ch.join(quant_ch, by: 'id')
emit:
samples: Channel = samples_ch
}
```
Entry workflow
The entry workflow is defined as follows:
```nextflow
workflow {
read_pairs_ch = channel.fromFilePairs(params.reads, checkIfExists: true, flat: true)
(fastqc_ch, quant_ch) = RNASEQ(read_pairs_ch, params.transcriptome)
multiqc_files_ch = fastqc_ch.mix(quant_ch).collect()
MULTIQC(multiqc_files_ch, params.multiqc)
}
```
Rewrite this workflow based on the updated params, processes, and subworkflows:
```nextflow
nextflow.enable.types = true
workflow {
samples_ch = RNASEQ(params.reads, params.transcriptome)
multiqc_files_ch = samples_ch
.flatMap { r -> [r.fastqc, r.quant] }
.collect()
MULTIQC(multiqc_files_ch, params.multiqc)
}
```
The `reads` param was refactored as a channel of records, so it can be passed directly to `RNASEQ`. It is compatible with the records expected by `RNASEQ`.
The `RNASEQ` workflow now returns a single combined channel, so the `mix` operation is no longer needed. The `flatMap` operator is used to extract the files from each record in `samples_ch`.
## Additional resources
See the following links to learn more about static typing:
- [Preparing for static typing][static-typing-prep-page]
- [Typed processes][process-typed-page]
- [Typed workflows][workflow-typed-page]
- [Types][stdlib-types]
- [Records][script-records]
- [Record type syntax][syntax-record-type]
[config-params]: ../config#parameters
[migrating-workflow-outputs]: ./workflow-outputs
[pipeline-composition]: ../workflow-typed#pipeline-composition
[process-reference-typed]: ../reference/process/inputs-outputs-typed
[process-typed-page]: ../process-typed
[rnaseq-nf-page]: ./rnaseq-nf
[script-records]: ../script#records-and-tuples
[static-typing]: ../static-typing
[stdlib-types]: ../reference/stdlib-types
[static-typing-prep-page]: ../static-typing-prep
[syntax-record-type]: ../reference/syntax#record-type
[typed-parameters-page]: ../typed-parameters
[vscode]: ../vscode
[workflow-typed-page]: ../workflow-typed