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(..)
orimport A(x, y)
) are not implemented yet
Cheatsheet
Suppose there's a module with A
namespace containing x
and y
.
Import command | What is brought into scope |
---|---|
import A | A.x , A.y |
import A(x) | A.x , A.y , x |
import A(..) | A.x , A.y , x , y |
import A as Z | Z.x , Z.y |
import A(x) as Z | Z.x , Z.y , x |
import A(..) as Z | Z.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 })