Types

Dust is a dynamically typed language. Its primitive types available are the following:

Booleans

A boolean can either be true or false. They support the usual boolean operations:

// logical and
x && y

// logical or
x || y

// logical not
!x

The && and || operators perform short-circuit evaluation. For example, false && f() evaluates to false without evaluating f().

Numbers

⚠️ Dust doesn't parse floating point literals yet

There is not distinction between integers and floating point numbers.

42   // a number
42.1 // still a number

They support the usual arithmetic infix operators:

-x // unary negation
x + y // addition
x * y // multiplication
x - y // subtraction
x / y // division
x % y // modulo

Strings

⚠️ Dust doesn't parse escape characters in strings yet

Strings are created using " characters and are immutable

"hello!"

Nil

nil (also called "unit" or "empty tuple" in other languages) is a value that can either be used as a return value for functions that do not return any meaningful values (e.g. print()) or couldn't return any possible values for a certain input. There is only one instance of nil (meaning that nil == nil)

Tuples

Tuples are immutable sequences of data. Currently only 2 and 3 elements tuples are supported. A possible use case is to return multiple values from a function

#(a, b)
#(a, b, c)

Lists

Lists are internally represented as linked lists.

// Empty list
[]

// The cons operation allows to prepend in O(1) time an head to a list, e.g.
[1, ..[2, ..[3, ..[]]]] // => [1, 2, 3]

// But there's also a syntax sugar to do the former
[1, 2, 3]

You can mix those syntaxes as long as the `..` comes last:
[1, 2, ..tl]

Maps

Maps are key-value dictionaries. Only strings are currently supported as keys.

// Empty map
#{ }

// a map with two entries
#{ "x" => 0, "y" => 1 }

let m = #{ "x" => 0 }
// `cons` a map into another
#{ "y" => 1, ..m } // => #{ "x" => 0, "y" => 1 }

// entries can also be computed:
let key = "x";
#{ key => 42 } // => #{ "x" => 42 }

Let declarations

There are two types of let declarations:

A toplevel let is a statement:

let name = value;
// name is bound here

and binds the value x to the assigned value for the rest of the program.

Let statements can have a pub modifier that makes the value visible from other modules

pub let name = value;

Otherwise, if a let appears inside a block, it is considered an expression:

{
    let name = value;
    body // name is bound here
}
// name is unbound here

For instance, you can write:

// num is now equal to 2
let num = {
  let x = 1;
  x + x
}

If you want to nest let expressions, you can do that in the same block instead of creating nested blocks:

{
  let x = 42;
  let y = y + 1;
  x + y
}

A let declaration is always immutable, meaning that the value cannot be changed afterwards, and further re-declaration of the same value are allowed, but shadow the original value.

Conditionals

Conditionals can be written using the if espression:

if condition {
  expr1
} else {
  expr2
}

The former is evaluated to expr1 whenever condition evaluates to true, and evaluates to expr2 when condition evaluates to false.

Warning It will emit a runtime error whenever condition evalutes to a value that is not a boolean.

Syntax sugar for nested if expressions is also available:

if condition1 {
  expr1
} else if condition2 {
  expr2
} else {
  expr3
}

if is an expression, meaning you can actually write code like

let max_num =
  if a >= b {
    a
  } else {
    b
  }

Functions

You can declare anonymous function with the following syntax:

// no params
fn { body }

// 1 param
fn x { body }

// 3 params
fn x, y, z { body }

Function are a first-class citizen, meaning that they are treated as a regular value. As a consequence, they can be assigned to values, passed as function arguments, or returned from functions.

For this reason, there is no concept of "named function", but you can assign them to variables using let declarations

let identity = fn x {
  x
}

identity(42) // => 42

You can use functions and let declarations together to archive recursion, which is the only looping mechanism in the language. Tail calls are optimized to have a O(1) stack usage, so you can safely recur without incurring in a stack overflow.

And finally, you can use lexical closures like in any other language:

let add_curried = fn a {
  fn b { a + b }
}

add_curried(1)(2) // => 3

Pattern matching

You can pattern match value using the match expression:

match expression() {
  // you can match constants like numbers, bool, strings, or nil
  42 => "expression is 42",
  [] => "expression is the empty list",
  [hd, ..tl] => "expression is a list with head `hd` and tail `tl`",
  #(x, y) => "matching a tuple",
  #{ } => "matching the empty map",
  #{ "k" => x, ..rest } => "matching a map with a key `k`",
  _ => "catchall clause",
}

The match expression tries to unify the given expression with one clause at a time, and if no clause succeeds it results in a runtime error.

let x = 42;
match x {
  0 => "zero",
  1 => "one",
}

// Execution error:
// No match for 42
//   at <repl>

You can also nest the patterns:

let example = fn x {
  match x {
    [1, 2, third, .._] => third,
    _ => nil
  }
};

example([1, 2, "x", 4, 5]) // => "x"
example([]) // => nil
example(42) // => nil

Blocks

blocks are a syntax that allow to have locally scoped let expressions or multiple expressions. You can explicitly create a block using the bracket syntax:

{
  let name = f();
  g(); // the result of this expression is discared
  h()
}
// the block is evaluated as `h()`

Functions and if expressions implicitly create blocks:

fn {
  let x = expr;
  value
}

if b {
  let x = expr;
  value
} else {
  nil
}

Blocks can be used anywhere, such as function call, if expressions, or other blocks:

let id = fn x { x }

id({ let x = 1; x + x }) // => 2

Modules and imports

A Dust program can be split in many modules. A (public) value defined in a different module can then be imported and used from other modules.

A module's namespace is equals to it's path (with regard to the project's source-directory folder - usually src):

For example, if the my-dust-project/src/A/B.ds file contains:

pub let x = 42;

Then we can use the import statements to make those values available:

A.B.x // unbound

import A.B;

A.B.x // => 42

Namespaces can be aliased:

import A.B as Z;

Z.x // => 42

Or make some or all the namespaces values available in the scope:

import A(x, y);
// `A.x`, `A.y`, `x` and `y` are in scope now

import A(..);
// every value inside `A` is in scope now

It's also possible to have scoped import expressions inside blocks:

{
  import A;
  A.x
}
// `A` namespace is not accessible here

⚠️ Scoped imports, and unqualified imports (such as import A(..) or import A(x, y)) are not implemented yet

Cheatsheet

Suppose there's a module with A namespace containing x and y.

Import commandWhat is brought into scope
import AA.x, A.y
import A(x)A.x, A.y, x
import A(..)A.x, A.y, x, y
import A as ZZ.x, Z.y
import A(x) as ZZ.x, Z.y, x
import A(..) as ZZ.x, Z.y, x, y

Pipe (|>) syntax sugar

The |> operator is a syntax sugar used to avoid nested function calls.

a |> f(x_1, ..., x_n)

Desugars to:

f(a, f_1, ..., f_n)

It can also be nested:

42
|> double()
|> increment_by(1)

Desugars to:

increment_by(double(42), 1)

use syntax sugar

The use keyword is a syntax sugar used to avoid deeply nested callbacks. For example:

{
  use a, b <- f(x, y)
  expr1;
  expr2
}

Desugars to:

f(x, y, fn a, b {
  expr1;
  expr2
})

It can be used with 0-n args:

use <- f()
use a <- f()
use a, b, c <- f()

The use syntax is only accepted inside blocks.

use is inspired by Roc's backpassing syntax and Gleam's use operator.

Syntax cheatsheet:

// types
1
1.1
true
false
nil

// conditionals
if condition { x } else { y }

// let statements
let x = 42

// let expressions (allowed inside blocks)
{
    let x = 42;
    x + 1
}

import A
import A.B

A.x

fn { body }
fn x { body }
fn x, y { body }


a |> f(x, y)
// desugars to
f(a, x, y)


{
    use a <- f(x, y)
    expr
}
// desugars to
f(x, y, fn a { expr })