NixOS is, to my surprise, an already 20+ years old Linux distribution (as of 2023), based on the Nix package manager, itself based on the Nix expression language, which will be the focus of this article and this series (I won’t talk about NixOS, nor about the Nix package manager, neither about how to use Nix’s language to interact with Nix package manager).
Nix’s language is:
- functional: essentially, λ-calculus based;
- lazy: expressions’s evaluation is delayed as much as possible;
- dynamically typed: typing is performed during evaluation, not at compilation (i.e. not statically);
- pure: in the modern, practical sense, we can still do I/O for instance;
- minimal: the language has been designed for a peculiar/narrow purpose; its feature set is limited, and it isn’t the most sensible choice for general purpose computing.
It “feels” either like a lazy Scheme (tuples, dynamic typing), or a stripped-down Haskell (laziness, not a S-expression based syntax).
The goal of the current article is to introduce elementary technical elements:
- Installation;
- Running code;
- Major language features (variables, functions, conditions, etc.).
We’ll later use this knowledge to explore recursive functions in the next two articles, and show how much we can achieve with mere recursive functions. We’ll then shift our focus on Nix’s lists & sets, before concluding with two project-based articles, the last one being about implementing a simple λ-calculus interpreter, which is the main goal of this series.
All the code is available on github.
Note: Thanks Pete Dietl for the various suggestions!
Installation
We don’t need to install NixOS to use the Nix-the-language; one still need to install the Nix package manager, but it will live happily without much interference, on any Linux distribution. You may want to refer to the official quick start, or to Archlinux’s wiki Nix page (even if you don’t use Archlinux, you may find interesting bits for your distribution there):
(root)% yes | pacman -S nix
...
If you don’t care much for the package manager itself, you can perform a
single-user installation, which implies chown(8)-ing
/nix
:
(user)% nix repl
error: creating directory '/nix/store': Permission denied
(root)% mkdir -p /nix/; chown -R $user:$group /nix/
Executing code
There’s a REPL available (corresponding manual page):
% nix repl
Welcome to Nix 2.11.1. Type :? for help.
nix-repl> let a = 3; b = 2; in a * b
6
nix-repl> ^D
Code can also be executed from a file:
% cat > /tmp/t.nix
let a = 3; b = 2; in a * b
^D
% nix-instantiate --eval /tmp/t.nix
6
The command can be used as a shebang:
% cat > /tmp/t.nix
#!/usr/bin/env -S nix-instantiate --eval
let a = 3; b = 2; in a * b
^D
% chmod +x /tmp/t.nix
% /tmp/t.nix
6
Hello, World
Sketching
Let’s demonstrate a bunch of features within a single script, using variants
of a hello world. First, note that Nix doesn’t come a regular
print
-like command, nor does it have statements. Furthermore,
each script must evaluate to a single value. Let’s see
what does it mean (I’ll progressively revisit in greater details
what’s presented here throughout the series, so you may have to
use your intuition for now).
Consider for instance the most basic hello world, which
simply evaluates to the string "hello world"
:
#!/usr/bin/env -S nix-instantiate --eval
"hello world"
"hello world"
Now if we want to add a second variant to the same script,
say, to exemplify string concatenation: Nix interprets
it as a function call (where "hello world"
is the function and
"hello"+" "+"world"
an argument):
#!/usr/bin/env -S nix-instantiate --eval
"hello world"
"hello"+" "+"world"
error: attempt to call something which is not a function but a string: "hello world"
at ./nix/hw/hw1.nix:2:1:
1| #!/usr/bin/env -S nix-instantiate --eval
2| "hello world"
| ^
3| "hello"+" "+"world"
You may be tempted to use a semicolon ;
to separate the
two values, but as earlier stated, Nix doesn’t have (imperative)
statements:
#!/usr/bin/env -S nix-instantiate --eval
"hello world";
"hello"+" "+"world"
error: syntax error, unexpected ';', expecting end of file
at ./nix/hw/hw2.nix:2:14:
1| #!/usr/bin/env -S nix-instantiate --eval
2| "hello world";
| ^
3| "hello"+" "+"world"
Well, you may ask, what about returning a list? After glancing
at the documentation, you’d learn that lists are
written within brackets []
, its elements being whitespace-separated:
#!/usr/bin/env -S nix-instantiate --eval
["hello world" "hello"+" "+"world"]
error: syntax error, unexpected '+'
at ./nix/hw/hw3.nix:2:24:
1| #!/usr/bin/env -S nix-instantiate --eval
2| ["hello world" "hello"+" "+"world"]
| ^
3|
Almost there, we just need to wrap our expressions between parentheses (if you think about it, it’s a natural consequence of the fact that lists are whitespace-separated), but something unexpected’s happening:
#!/usr/bin/env -S nix-instantiate --eval
["hello world" ("hello"+" "+"world")]
[ "hello world" <CODE> ]
If you’re familiar with laziness, you might have (correctly) guessed that the concatenation hasn’t been evaluated. Can we find ways to force the evaluation?
There are some builtin ways to convert a list to a string, which as a side effect, will force the evaluation of a list’s elements; this also demands us to learn how to define a variable:
#!/usr/bin/env -S nix-instantiate --eval
let
xs = [ "hello world" ("hello"+" "+"world") ];
in
"${toString xs}"
"hello world hello world"
Exercise: The previous example exemplifies [a superfluous use of] string interpolation: try to simplify it
Solution:[+]An alternative to force the evaluation of a variable, is to use
builtins.deepSeq e1 e2
, which evaluates
e1
before returning e2
. The evaluation is performed, well, deeply:
if e1
is an intricate data-structure (e.g. a list, or a list of lists),
then it will be recursively evaluated. This means that builtins.deepSeq x x
will first force a complete evaluation of x
, and then return x
, hence
the following:
#!/usr/bin/env -S nix-instantiate --eval
let
xs = [ "hello world" ("hello"+" "+"world") ];
in
builtins.deepSeq xs xs
[ "hello world" "hello world" ]
The problem with those two last approaches is that they force
us to know more about the language that I’d want us to for now (we’ll purposely
avoid using Nix’s lists for a while). Instead, let’s look at one more solution:
while Nix does not provide a regular print
-like, it does provide a
builtins.trace e1 e2
function, which:
- prints
e1
(to stderr) and then; - evaluates
e2
.
This means that we can easily chain tracing (and inject tracing in statement-free code):
#!/usr/bin/env -S nix-instantiate --eval
builtins.trace(
"hello world"
) builtins.trace (
"hello"+" "+"world"
) "EOF"
trace: hello world
trace: hello world
"EOF"
Note: We could have taken a different approach, for instance
by defining an alternative function to toString
to “stringify” a list, but
we’ll explore using regular Nix lists in the
third article
of this series only. Getting to know builtins.trace
early on seems to be a wise choice anyway: you now know how to debug
Nix programs.
Note: The REPL comes with some tools to help with some of those issues; you may want to refer to its documentation for more as this won’t be covered here.
Examples
Finally, here’s an executable Nix file which executes variants of a hello world, progressively introducing you to various features of the language:
#!/usr/bin/env -S nix-instantiate --eval
/*
* Regarding ``with builtins;``, it is an import-like.
* From a user perspective, suffice to know, for now,
* that it means we won't have to prefix "trace" with "bulitins"
* everywhere.
*
* More on this later.
*
* Mind the two kinds of comments used throughout this file.
*/
with builtins;
# most basic
trace ("hello world")
# string concatenation
trace ("hello"+" "+"world")
# "variables" definition and usage
# NOTE: mind the semicolon after each variable
# declaration.
trace (
let
h = "hello";
w = "world";
in
h+" "+w
)
# strings "format" (w is a list containing a single
# element)
trace (
let
h = "hello";
w = ["world"];
in
"${toString h} ${toString w}"
)
# Same as before, but with a single list with two
# elements
trace (
let
hw = ["hello" "world"];
in
"${toString hw}"
)
# lambda function, list of argument (one arg)
trace (
(x: "hello "+x) "world"
)
# named function, list of argument (two args)
trace (
let
f = x: y: x+" "+y;
in
f "hello" "world"
)
# named function, "named parameters" (attribute sets)
trace (
let
hw = {h, w} : "${h} ${w}";
in
hw {h="hello"; w="world";}
)
# default parameters
trace (
let
hw = {h ? "hello", w ? "world"} : "${h} ${w}";
in
hw {h="hello";}
)
# if/then/else
trace (
let
hw = x: if x == "hello" then "hello world" else "bye";
in
hw "hello"
)
# Evaluation value for this script
"OK"
trace: hello world
trace: hello world
trace: hello world
trace: hello world
trace: hello world
trace: hello world
trace: hello world
trace: hello world
trace: hello world
trace: hello world
"OK"
Note: I’m voluntarily exploring the language partially for now; we’ll slowly be more thorough as we progress. Just make sure you get an intuitive understanding of what’s happening.
Exercise: Try to re-implement the previous program by returning
deepSeq
on a list containing each of the variants.
Exercise: Try to implement a few new variants of the
hello world
program.
Resources
If you want to dive further, have a look at (in no specific order):
- Nix by example (some of it seems to be dated; for instance Nix does now have floats);
- Nix Language, from the Nix manual;
- Nix - A One Pager, an almost one page introduction to the language;
- A tour of Nix, an in-the-browser, interactive introduction to the language;
The next article will focus on recursive functions: this will thus be an introductory course on functional programming.
Comments
By email, at mathieu.bivert chez: