Skip to main content

Semantics

The semantics of the Nextflow language are the meaning and behavior of each language construct. This page is a companion to the Syntax reference, which describes the grammar of the language (how constructs are written). See the Scripts page for a practical introduction to the language.

Values

Every expression in Nextflow produces a value, and every value has a type that determines the operations it supports. This section describes the kinds of values in the language and how they behave. See the Types reference for the full set of operations available on each type, and Syntax for the grammar of each literal.

Booleans

A boolean is either true or false. Booleans are produced by the relational and logical operators and consumed by constructs that test a condition, such as if/else and assertions.

Any value can be coerced to a boolean based on its truthiness. A value that coerces to true is truthy, and a value that coerces to false is falsy.

The truthiness of a value depends on its type:

TypeFalsy valuesTruthy values
Booleanfalsetrue
Numberzero (0, 0.0)any non-zero number
Stringempty string ('')any non-empty string
Collectionempty collection ([])any non-empty collection
Mapempty map ([:])any non-empty map
Everything elsenullany non-null value

For example:

assert true
assert 42
assert 'hello'
assert [1, 2, 3]
assert [key: 'value']

assert !false
assert !0
assert !''
assert ![]
assert ![:]
assert !null

Numbers

A number is either an integer or a floating-point number.

Numbers support the standard arithmetic operators. Arithmetic between two integers produces an integer, with one exception: division always produces a floating-point result. Use the intdiv method for integer division.

assert 2 + 3 == 5
assert 7 / 2 == 3.5 // division produces a float
assert 7.intdiv(2) == 3 // integer division

When an operation mixes an integer and a floating-point operand, the integer is coerced to floating-point.

See Integer and Float for the available operations.

Strings

A string is a sequence of characters, written with single or double quotes (see String for the grammar). Strings are immutable, and can be concatenated with the + operator:

def greeting = 'Hello, ' + 'world!'

A double-quoted string supports interpolation -- embedded expressions are evaluated and their results inserted into the string. A single-quoted string is a literal and is never interpolated:

def name = 'world'
println "Hello, ${name}!" // -> Hello, world!
println 'Hello, ${name}!' // -> Hello, ${name}!

An interpolated expression is written as ${expression}. The curly braces can be omitted for a simple variable or property reference:

def sample = [id: 'A', count: 42]
println "Sample $sample.id has $sample.count reads"
// -> Sample A has 42 reads

Each interpolated expression is evaluated when the string is created, and its result is converted to a string using the value's toString() method. Interpolation is eager -- the expression is evaluated immediately, not deferred:

def x = 1
def s = "x is ${x}"
x = 2
println s // -> x is 1

Single-quoted strings are not interpolated:

println 'Hello, ${name}!'
// -> Hello, ${name}!

See String for the available operations.

Null

The special value null represents the absence of a value. Accessing a property or calling a method on null raises a null reference error. Avoid null where possible. The safe navigation operator can be used to access a property that may be null.

Collections

A collection is a group of values. Nextflow provides three kinds of collection, which differ in whether they preserve the order of their elements and whether they allow duplicates:

CollectionOrderedDuplicates
Listyesyes
Setnono
Bagnoyes

A list is the most commonly used collection. It is written with square brackets, and its elements are accessed by index, starting at zero. Lists are mutable -- elements can be added, removed, or replaced:

def numbers = [1, 2, 3]
assert numbers[0] == 1

A set is an unordered collection that cannot contain duplicates. It is typically created from a list with the toSet() method:

def unique = [1, 2, 2, 3].toSet()
assert unique.size() == 3

A bag is an unordered collection that allows duplicates. It is used where the order of elements is not significant.

All collections can be iterated with higher-order functions and tested with the membership operators. The + operator returns a new collection containing the elements of both operands, rather than modifying either one:

assert [1, 2] + [3, 4] == [1, 2, 3, 4]

See List<E>, Set<E>, and Bag<E> for the available operations.

Maps

A map is a collection of key-value pairs, written with square brackets:

def scores = [alice: 100, bob: 92]

Values are accessed by key, using either the index operator or a property expression:

assert scores['alice'] == 100
assert scores.alice == 100

The + operator returns a new map containing the entries of both operands, with the right operand taking precedence on conflicting keys:

assert [a: 1, b: 2] + [b: 3] == [a: 1, b: 3]

See Map<K,V> for the available operations.

Records

Added in version 26.04

A record holds a set of named fields, where each field can have its own type. Records are created with the record() function:

def person = record(name: 'Alice', age: 42)

Fields are accessed by name:

assert person.name == 'Alice'

Records are immutable -- once a record is created, it cannot be modified. Combine two records using the + operator to produce a new record:

def older = person + record(age: 43)
// -> record(name: 'Alice', age: 43)

See Record for the available operations.

Tuples

A tuple is a fixed sequence of values, which may have different types. Tuples are created with the tuple() function:

def pair = tuple('sample_1', file('sample_1.fastq'))

Tuple elements are accessed by index:

assert pair[0] == 'sample_1'

Tuples are immutable -- once a tuple is created, its elements cannot be modified.

A tuple can be destructured into multiple variables, both in assignments and in closure parameters.

See Tuple for the available operations.

Closures

A closure is an anonymous function that is itself a value. A closure consists of a parameter list and a body, enclosed in curly braces:

def square = { v -> v * v }
assert square.call(9) == 81

Closures have the same parameter and return semantics as function.

A closure can access variables from the enclosing scope, and can declare local variables that exist only for the duration of each invocation:

def factor = 2
assert [1, 2, 3].collect { v -> factor * v } == [2, 4, 6]

When a closure receives a single tuple argument, the parameter list can destructure the tuple. The number of parameters must match the number of tuple elements:

def samples = [
tuple('sample_1', 100),
tuple('sample_2', 200),
]

samples.each { id, count ->
println "${id}: ${count}"
}

Within a nested closure, return exits the nearest enclosing closure, not the outer function.

The primary use of a closure is as an argument to a higher-order function -- a function that takes a closure and applies it, such as the iteration methods of a collection or the operators of a channel.

Operators

An operator is a symbol that performs an operation on one or two operands. The full set of operators and their grammar is listed in the Syntax reference. This section describes their semantics, grouped by category.

note

The operators described here are language operators, which apply to ordinary values. They are different from channel operators, which are functions for manipulating channels. See Operators for channel operators.

Arithmetic operators

Arithmetic operators perform numeric computation:

OperatorOperationExample
+addition2 + 3 -> 5
-subtraction5 - 2 -> 3
*multiplication2 * 3 -> 6
/division7 / 2 -> 3.5
%remainder (modulo)7 % 3 -> 1
**power (exponentiation)2 ** 8 -> 256

Division of two integers produces a floating-point result (7 / 2 is 3.5, not 3). To perform integer division, use the intdiv method: 7.intdiv(2) is 3.

Some arithmetic operators are overloaded for non-numeric types. In particular, + concatenates strings, lists, and maps:

assert 'foo' + 'bar' == 'foobar'
assert [1, 2] + [3, 4] == [1, 2, 3, 4]
assert [a: 1] + [b: 2] == [a: 1, b: 2]

Relational operators

Relational operators compare two values and produce a boolean:

OperatorOperation
==equal to
!=not equal to
<less than
>greater than
<=less than or equal to
>=greater than or equal to
<=>three-way comparison (spaceship)

In Nextflow, == tests value equality, not object identity -- two distinct objects with the same contents are equal:

assert [1, 2, 3] == [1, 2, 3]
assert 'hello' == 'hello'

Ordering operators (<, >, <=, >=) compare values according to their natural order: numbers numerically and strings alphabetically. The spaceship operator <=> returns -1, 0, or 1 depending on whether the left operand is less than, equal to, or greater than the right operand, and is useful for defining custom sort comparators:

def items = ['banana', 'apple', 'cherry']
items.toSorted { a, b -> a <=> b }
// -> ['apple', 'banana', 'cherry']

Logical operators

Logical operators combine boolean values. Their operands are coerced based on truthiness:

OperatorOperation
&&logical AND
||logical OR
!logical NOT

The && and || operators short-circuit: the right operand is evaluated only if necessary. For &&, the right operand is evaluated only if the left is truthy. For ||, it is evaluated only if the left is falsy. This allows the right operand to safely depend on the left:

if( reads != null && reads.size() > 0 )
// reads.size() is only evaluated if reads is not null

Bitwise operators

Bitwise operators operate on the individual bits of integer values:

OperatorOperation
&bitwise AND
|bitwise OR
^bitwise XOR
~bitwise NOT
<<left shift
>>right shift
>>>unsigned right shift
assert (0b1100 & 0b1010) == 0b1000   // -> 8
assert (0b1100 | 0b1010) == 0b1110 // -> 14
assert (1 << 4) == 16
note

The >> operator is also used in typed processes to emit values to a topic, and in workflow outputs to publish files.

Conditional operators

Conditional operators select a value based on a condition.

The ternary operator cond ? a : b evaluates cond (using truthiness) and returns a if it is truthy, otherwise b:

def label = sample.single_end ? 'single' : 'paired'

The Elvis operator a ?: b is a shorthand that returns a if it is truthy, otherwise b. It is commonly used to supply a default value:

def threads = params.threads ?: 1

In both cases, only the selected branch is evaluated.

Navigation operators access properties and methods of a value.

The dot operator a.b accesses the property b (or calls the method b()) of a:

myFile.name
myFile.getName()

The safe dot operator a?.b returns null if a is null, instead of raising a null reference error. This avoids the need for explicit null checks:

def name = sample?.reads?.name
// null if sample or sample.reads is null

The spread dot operator a*.b applies the property access or method call to each element of a collection, returning a list of the results:

def files = [file('a.txt'), file('b.txt')]
files*.name
// -> ['a.txt', 'b.txt']
// equivalent to: files.collect { f -> f.name }

Range operators

Range operators create a list of consecutive values. A range can be formed from any ordered type, such as integers or characters:

OperatorOperation
..inclusive range
..<right-exclusive range
assert (1..5) == [1, 2, 3, 4, 5]
assert (1..<5) == [1, 2, 3, 4]
assert ('a'..'f') == ['a', 'b', 'c', 'd', 'e', 'f']

Membership operators

Membership operators test whether a collection contains a value:

OperatorOperation
inmembership
!innegated membership
assert 2 in [1, 2, 3]
assert 4 !in [1, 2, 3]

For a map, the in operator tests for the presence of a key.

Type operators

Type operators convert or test the type of a value:

OperatorOperation
astype cast
instanceoftype test
!instanceofnegated type test

The as operator casts a value to a different type (see Types). The instanceof operator tests whether a value is an instance of a given type:

assert ('42' as Integer) == 42
assert 'foo' instanceof String
assert 42 !instanceof String

Regex operators

Regex operators match a string against a regular expression:

OperatorOperation
=~regex find
==~regex match

The =~ operator is truthy if the pattern occurs anywhere in the string, whereas ==~ is true only if the entire string matches the pattern:

assert 'hello world' =~ /world/         // find: pattern occurs in the string
assert !('hello world' ==~ /world/) // match: pattern must match the whole string
assert '2.7.3' ==~ /\d+\.\d+\.\d+/

Operator precedence

When an expression contains multiple operators, precedence determines the order in which they are applied. Operators with higher precedence are applied before those with lower precedence. For example, * has higher precedence than +, so 1 + 2 * 3 is 7, not 9.

The full precedence order, from highest to lowest, is listed in the Syntax reference. The most important rules to remember are:

  • Property access, method calls, and index expressions bind most tightly.
  • Unary operators (!, ~, unary -) bind more tightly than binary arithmetic.
  • Arithmetic binds more tightly than comparison, which binds more tightly than the logical operators.
  • The conditional operators (?:) have the lowest precedence.

When in doubt, use parentheses to make the intended grouping explicit:

(1 + 2) * 3   // -> 9
1 + 2 * 3 // -> 7

Statements

A statement performs an action, such as declaring a variable, branching on a condition, or raising an error. Statements are executed in order, top to bottom.

Variable declarations

A variable is declared with the def keyword and an initial value:

def x = 42

Multiple variables can be declared in a single statement by destructuring a tuple. The number of variables must match the number of elements:

def (id, reads) = tuple('sample_1', file('sample_1.fastq'))
// id -> 'sample_1'
// reads -> file('sample_1.fastq')

Each variable has a scope, which is the region of code in which it can be used:

  • Variables declared in a function, along with the function's parameters, exist for the duration of each function call. The same applies to closures.

  • Workflow inputs exist for the entire workflow body. Variables declared in the main: section exist for the main, emit, and publish sections. Named outputs are not variable declarations and therefore have no scope.

  • Process input variables exist for the entire process body. Variables declared in the process script, exec, and stub sections exist only within their respective section, with one exception -- variables declared without the def keyword also exist in the output section.

  • Variables declared in an if or else branch exist only within that branch:

    if( true )
    def x = 'hello'
    println x // error: `x` is undefined

    // solution: declare `x` outside of the if branch
    def x
    if( true )
    x = 'hello'
    println x

A variable cannot be declared with the same name as another variable in the same scope or an enclosing scope:

def clash(x) {
def x // error: `x` is already declared
if( true )
def x // error: `x` is already declared
}

Assignments

An assignment updates the value of a target, which can be a variable, an index expression, or a property expression:

x = 42
list[0] = 'first'
map.key = 'value'

Multiple targets can be assigned in a single statement by destructuring a tuple, in the same way as a declaration but without def:

(id, reads) = tuple('sample_1', file('sample_1.fastq'))

A compound assignment combines a binary operation with the assignment. For example, x += y is equivalent to x = x + y -- the target is read, combined with the source, and the result assigned back:

def x = 1
x += 2
assert x == 3

Because the underlying operators are overloaded, compound assignment also works for strings and collections:

def items = [1, 2]
items += [3]
assert items == [1, 2, 3]

The following compound assignment operators are available, each corresponding to a binary operator: +=, -=, *=, /=, %=, **=, &=, |=, ^=, <<=, >>=, >>>=.

The elvis assignment ?= is a special case: x ?= y is equivalent to x = x ?: y, assigning y only if x is currently falsy. It is useful for setting a default value:

def x = null
x ?= 42
assert x == 42

if/else

An if/else statement conditionally executes one of two branches based on a boolean condition. The condition does not need to be a boolean value -- it is coerced to a boolean using truthiness:

def files = file('*.txt')
if( files ) {
println "Found ${files.size()} files"
}
else {
println 'No files found'
}

An if/else statement is a statement, not an expression -- it does not produce a value. To select between two values based on a condition, use a conditional expression instead:

def message = files ? "Found ${files.size()} files" : 'No files found'

Iteration

Nextflow does not support imperative loop statements such as for and while. Instead, iteration is expressed declaratively using higher-order functions -- methods that take a closure and apply it to each element of a collection.

For example, instead of a for loop:

// Groovy (not supported in Nextflow)
def result = []
for( x in [1, 2, 3] ) {
result.add(x * x)
}

Use the collect method to transform each element:

def result = [1, 2, 3].collect { x -> x * x }
// -> [1, 4, 9]

Higher-order functions make the intent of the iteration explicit. Common operations include:

  • each: perform a side effect for each element
  • collect: transform each element into a new value (i.e. map)
  • find / findAll: select the first / all elements that satisfy a condition (i.e. filter)
  • inject: combine all elements into a single value (i.e. reduce or fold)
  • any / every: test whether any / all elements satisfy a condition
def numbers = [1, 2, 3, 4, 5]

numbers.each { v -> println v } // print each number
numbers.collect { v -> v * 2 } // -> [2, 4, 6, 8, 10]
numbers.findAll { v -> v % 2 == 0 } // -> [2, 4]
numbers.inject(0) { acc, v -> acc + v } // -> 15
numbers.every { v -> v > 0 } // -> true

A higher-order function returns a value rather than mutating external state, which makes it easier to reason about and avoids common errors. For example, inject accumulates a result without an external accumulator variable:

// instead of mutating an external variable
def total = 0
[1, 2, 3].each { v -> total += v }

// accumulate the result directly
def total = [1, 2, 3].inject(0) { acc, v -> acc + v }

See Iterable<E> for the full set of higher-order functions for collections such as lists and sets. To process the values in a channel, use operators such as map, filter, and reduce instead.

Assertions

An assert statement evaluates a boolean condition and raises an error if the condition is false. Like an if condition, the asserted expression is coerced to a boolean using truthiness:

assert params.input != null : 'The --input parameter is required'

If the condition is false, an error is raised. The optional message after the colon is included in the error. If no message is given, Nextflow reports the values of the sub-expressions in the condition:

assert 2 + 2 == 5
// Assertion failed:
// assert 2 + 2 == 5
// | |
// 4 false

Assertions are intended for verifying invariants -- conditions that should always hold if the code is correct. They are commonly used in tests and for sanity checks. To validate user inputs or report expected error conditions, use the error function instead, which produces a cleaner message without the assertion trace.

Error handling

When an error occurs at runtime, an exception is raised. An exception propagates up through the call stack until it is caught by an enclosing try/catch statement, or else it reaches the Nextflow runtime and terminates the pipeline.

The recommended way to raise an error in pipeline code is the error function, which stops the pipeline with a clear error message:

if( params.aligner !in ['bowtie2', 'bwamem'] )
error "Unsupported aligner: ${params.aligner}"

The lower-level throw statement can be used to raise a specific exception type, but error should be preferred for reporting pipeline errors, as it produces a cleaner message and does not require constructing an exception:

// preferred
error 'Something failed!'

// equivalent, but more verbose
throw new Exception('Something failed!')

A try/catch statement is appropriate when an error is expected and can be handled in pipeline logic -- for example, falling back to a default value when an optional file cannot be read:

def config
try {
config = file('custom.config').text
}
catch( e: IOException ) {
log.warn 'Could not read custom.config, using defaults'
config = ''
}

A catch clause matches an exception if the raised exception is an instance of the declared type. If no catch clause matches, the exception propagates to the next enclosing try/catch statement, or terminates the pipeline. Use try/catch only for errors you intend to recover from -- otherwise, let the error propagate so the failure is visible.

note

The error function and try/catch handle errors in pipeline code (functions, closures, the workflow body). They are distinct from two other mechanisms that operate at different levels:

  • The errorStrategy directive determines what happens when a process task fails (e.g. a command exits with a non-zero status): the task can be retried, ignored, or terminate the pipeline. This is the appropriate mechanism for handling failures of the tools that a process executes.

  • The onError workflow handler runs when the pipeline as a whole fails, allowing for cleanup or notification.

Functions

A function is a named, reusable block of code that takes zero or more parameters and may return a value:

def sayHello(name: String) {
println "Hello, ${name}!"
}

def add(a: Integer, b: Integer) -> Integer {
a + b
}

Parameters

Arguments are passed by reference -- the function receives a reference to the same object, not a copy. Mutating a mutable argument (such as a list or map) inside a function affects the caller's object:

def addItem(list, item) {
list.add(item)
}

def items = [1, 2]
addItem(items, 3)
println items // -> [1, 2, 3]

To avoid this, create a copy before mutating, or use operations that return a new value (e.g. list + [item] instead of list.add(item)).

Reassigning a parameter, on the other hand, does not affect the caller. The parameter is a separate reference to the same object, so assigning a new value to it changes only the function's local reference:

def replace(list) {
list = [4, 5, 6] // reassigns the local reference only
}

def items = [1, 2, 3]
replace(items)
println items // -> [1, 2, 3]

Default parameter values

A function parameter can declare a default value, which is used when the caller does not supply an argument for that parameter:

def greet(name, greeting = 'Hello') {
"${greeting}, ${name}!"
}

greet('world') // -> Hello, world!
greet('world', 'Hi') // -> Hi, world!

Parameters with default values must come after parameters without defaults. A default value can be any expression, and is evaluated each time the function is called without that argument.

Named arguments

A function call may use named arguments -- arguments specified by name rather than position. Named arguments are collected into a map and passed as the first positional argument to the function:

file('hello.txt', checkIfExists: true)

is equivalent to:

file([checkIfExists: true], 'hello.txt')

Named arguments are commonly used by built-in functions and process directives to specify optional settings:

// process directive with named options
accelerator 4, type: 'nvidia-tesla-v100'

// channel factory with named options
channel.fromPath('*.txt', checkIfExists: true)

Because named arguments are collected into a single map, their order does not matter, and they can be freely mixed with positional arguments. A function that accepts named arguments must declare a Map as its first parameter.

Returns

A function returns the value of its last evaluated expression, or the value of an explicit return statement:

def add(a, b) {
a + b // implicit return
}

def addExplicit(a, b) {
return a + b
}

If a function has multiple return statements (including implicit returns), they must either all return a value or all return nothing. When a value is returned, every conditional branch must return a value:

def isEven1(n) {
if( n % 2 == 1 )
return // error: a return value is required here
return true
}

def isEven2(n) {
if( n % 2 == 0 )
return true
// error: a return value is required here
}

If the last statement of a function is not a return or expression statement, it is equivalent to a bare return with no value.

Processes

A process defines a task that runs in an isolated environment. A process is called like a function from a workflow, but instead of running immediately, each call adds a node to the dataflow graph. Nextflow creates a separate task for each input (or combination of inputs) and executes these tasks asynchronously as their inputs become available, potentially in parallel and on distributed compute.

See Processes for a full description of how to define and use processes.

Workflows

A Nextflow workflow describes a dataflow -- a graph of processes and operators connected by channels. See Processes and Workflows for a full description of how to define and compose them.

Definition vs execution

A workflow body is not executed step by step like an ordinary function. Instead, it is evaluated once, up front, to construct the dataflow graph. The statements in a workflow body declare how processes, operators, and channels are wired together. The actual computation happens afterward, asynchronously, as data flows through the graph.

In this sense, a workflow definition behaves like a compile-time macro: it runs once to build the dataflow graph, and the operations it describes (process tasks, operator logic) execute later as data becomes available.

Consider:

workflow {
ch_input = channel.of(1, 2, 3)
ch_result = ch_input.map { v -> expensive(v) }
ch_result.view()
}

When the workflow body runs:

  1. channel.of(1, 2, 3) creates a channel.
  2. .map { ... } connects a map operator to that channel and returns a new channel. The closure is not called yet -- it is registered to run later, once per value.
  3. .view() connects a view operator.

Only after the entire graph is constructed does Nextflow begin executing it: the channel emits its values, the map closure runs for each value, and view prints each result.

This has several consequences:

  • Channel contents cannot be accessed directly. Because the workflow body runs before any data flows, you cannot inspect the values in a channel from within the workflow body. You must use an operator or process to act on each value:

    // does NOT work -- the channel has no values yet when this runs
    def first = ch_input[0]

    // correct -- the closure runs later, for each value
    ch_input.map { v -> transform(v) }
  • Operator closures run asynchronously. The closure passed to an operator such as map or filter is invoked later, once per channel value, and possibly out of order. It should not depend on mutable state shared with other closures.

  • The order of statements describes connections, not execution order. Two independent branches of a workflow are constructed in the order written, but their tasks may execute concurrently.

This is the fundamental difference between the workflow definition (evaluated synchronously to build the graph) and the workflow execution (runs the graph asynchronously). See Dataflow for more information.

Scripts

A script is a single Nextflow source file. It consists of script declarations -- such as workflows, processes, functions, and types -- and may use declarations from other scripts.

Execution

When a script is executed, its entry workflow -- the workflow with no name -- serves as the entrypoint. A script that contains only statements, with no declarations, is treated as the body of an implicit entry workflow. To be executable, a script must either be a code snippet or define an entry workflow.

Inclusion

A script can use the definitions of another script through an include declaration:

include { hallo as sayHello } from './some/module'

The include source should refer to either a local path (e.g. ./module.nf), a registry module (e.g. nf-core/fastq), or a plugin (e.g. plugin/nf-hello).

The following definitions can be included from a script:

  • Functions
  • Processes
  • Named workflows
  • New in 26.04: Record types
  • New in 26.04: Enum types

The entry workflow of an included script is ignored. This allows the same script to be executed directly or included by another script.

Types

Every value in Nextflow has a type, which determines the set of operations that can be performed on it. The available types are described in the Types reference.

Nextflow supports two styles of typing:

  • Dynamic typing (the default): variables and parameters do not declare a type, and the type of each value is determined at runtime. This is the traditional behavior.

  • Static typing (opt-in): variables, parameters, process inputs/outputs, and workflow inputs/outputs can declare a type, which is checked at compile time. Static typing is enabled with the nextflow.enable.types feature flag.

A type annotation is written after the name, separated by a colon:

def count: Integer = 0
def name: String = 'sample'
def reads: List<Path> = []

Nullable types

A type annotation can be suffixed with ? to indicate that the value may be null:

def x: String? = null

Generic type arguments

Some types are generic -- they are parameterized by one or more type arguments that specify the types of their contents. A type argument is written in angle brackets after the type name:

  • List<E> is a list whose elements have type E
  • Channel<E> is a channel whose values have type E
  • Map<K,V> is a map whose keys have type K and values have type V

For example:

// a list of strings
def sequences: List<String> = ['ATCG', 'GCTA']

// a map from string keys to integer values
def counts: Map<String,Integer> = [sample1: 1000, sample2: 1500]

// a channel of file paths
def ch_bams: Channel<Path> = channel.fromPath('*.bam')

Generic type arguments allow the type checker to verify that the contents of a collection or channel are used consistently -- for example, that a list of Path is not mistakenly treated as a list of String.

Record types

Added in version 26.04

A record type is a user-defined type that specifies a set of named fields, each with its own type (see Syntax for the declaration grammar):

record Sample {
id: String
fastq_1: Path
fastq_2: Path?
}

A field can be marked as optional by appending ? to its type.

A record created with the record() function has the generic type Record and makes no guarantee about which fields it provides. A record type, by contrast, specifies a minimum set of required fields, and is used to make a stronger guarantee in a particular context, such as a workflow, process, or function input.

Record types are structural (or duck-typed): a record satisfies a record type if it provides at least the fields declared by that type. The record may have additional fields, and it does not matter how the record was created. A record is validated against a record type when it is supplied as an argument to a workflow, process, or function that declares a record type input, or cast to a record type with the as operator:

def hello(sample: Sample) {
println "Hello sample ${sample.id}!"
}

workflow {
// error: missing `fastq_1` field required by Sample
hello(record(id: '1', fastq_2: file('1_2.fastq')))

// ok: all required fields are present (extra fields are allowed)
hello(record(id: '1', fastq_1: file('1_1.fastq'), single_end: true))
}

Because matching is structural, two record types with the same fields and field types are interchangeable, even if they have different names.

Enum types

An enum type is a user-defined type with a fixed set of named values (see Syntax for the declaration grammar):

enum Day {
MONDAY,
TUESDAY,
WEDNESDAY,
THURSDAY,
FRIDAY,
SATURDAY,
SUNDAY
}

Each value is accessed as a property of the enum type:

def day = Day.MONDAY

An enum type is useful for representing a value that can take only one of a fixed set of options, in place of a free-form string.

Type casting

A value can be cast to a different type explicitly with the as operator. The value must be convertible to the target type, or an error is raised:

def x = '1h' as Duration
def n = '42' as Integer

A value is also cast implicitly in the following situations:

  • Numeric coercion -- when an arithmetic operation mixes an integer and a floating-point operand, the integer is coerced to a floating-point number.

  • Boolean coercion -- in a condition, such as if/else, assertions, or the logical operators, any value is coerced to a boolean according to its truthiness.

  • String coercion -- when a value is interpolated into a string, it is converted to a string with its toString() method.