Nix can be daunting

 · 8 min · torgeir

Nix can be daunting at first, as many parts of it really differs from other languages. But you don't really need to know that much of nix the language to simply install some software and manage a little configuration.

Nix

This post is part of the Series: Nix can.

Here’s a few features of the language I wish I discovered a little earlier on. Some of these features are part of the module system more than the language itself, but I’ll include them anyways as they are nescessary to show some useful examples.

A functional language

The language is functional. It is focused around creating object structures, called attribute sets, declaratively expressing what software should be installed and what its configuration should look like.

However, the language can do a lot more. And like much of what you find in the Nix ecosystem, it is.. different, in many aspects, to languages you might be used to. Its also dynamically typed, which means you won’t catch type errors until you evaluate it.

Attribute sets

It excels at creating attribute sets - or objects, maps or dictionaries as they’re often called in other languages.

An attribute set looks like this.

{ key = 123; }
  # => { key = 123; }

Lookup attributes

You can lookup attributes with a .

{ a = 42; }.a
  # => 42

Nested attribute sets

Nested attribute sets can be created like you always wanted to do with json!

{ a.b.c = 42; }
  # => { a = { ... }; }

Nix is actually a great language for producing json from these attribute sets, which is something you can do with some of the builtin functions.

Join attribute sets

You can join multiple attribute sets with the //, like this.

{ a = 1; b = 2; } // { c = 3; }
  # => { a = 1; b = 2; c = 3; }

A REPL (read eval print loop)

You can launch a repl to experiment with the language. This one also imports nixpkgs, so you can dig around, inspect and build existing derivations. It is useful for learning and experimenting with the language.

nix repl --expr 'import <nixpkgs> {}'

You’ll find packages under pkgs, so e.g. try pkgs.node<tab> to see what exists on https://github.com/NixOS/nixpkgs/ starting with the name node.

nix-repl> pkgs.node
pkgs.node-glob                pkgs.nodejs-14_x              pkgs.nodejs-slim_14           pkgs.nodejs_18
pkgs.node-manta               pkgs.nodejs-16_x              pkgs.nodejs-slim_16           pkgs.nodejs_20
pkgs.node-problem-detector    pkgs.nodejs-16_x-openssl_1_1  pkgs.nodejs-slim_18           pkgs.nodejs_21
pkgs.node2nix                 pkgs.nodejs-18_x              pkgs.nodejs-slim_20           pkgs.nodejs_latest
pkgs.nodePackages             pkgs.nodejs-slim              pkgs.nodejs-slim_21           pkgs.nodenv
pkgs.nodePackages_latest      pkgs.nodejs-slim-14_x         pkgs.nodejs-slim_latest       pkgs.nodepy-runtime
pkgs.nodehun                  pkgs.nodejs-slim-16_x         pkgs.nodejs_14
pkgs.nodejs                   pkgs.nodejs-slim-18_x         pkgs.nodejs_16

Let expressions

let is an expression, like in many functional languages (lisp comes to mind), and the variables you define are scoped to the expression. This means the bound variables are available only after the in ... part of the expression. What comes after it ends up as the result when it is evaluated..

let
  result =
    let
      nested = { a = 42; };
      evil = 666;
    in { object = nested; value = evil; };
in result
  # => { object = { ... }; value = 666; }

Weird REPL commands

There are lots of these weird commands starting with :.

Want to make sure you see the whole result of the let expression above? Put a :p in front of it.

:p let
  result =
    let
      nested = { a = 42; };
      evil = 666;
    in { object = nested; value = evil; };
in result
  # => { object = { a = 42; }; value = 666; }

(Notice { ... } has now become { a = 42; })

I have to dig more into these repl commands later. Look them up with another weird command :?

Semicolons

The placement of semicolons feel kinda arbitrary at first ; You need them in attribute set key-value pairs and when binding variables in a let block, before the in-part.

Also, the auto-formatters are strict, so you need them in the correct places to please it. Or else it won’t format your code.

Multiline strings

You can do multiline strings with 2x single quotes at the start and end of the string, and you can interpolate code using ${code here}.

let
  content = ''
    muli
    line
    this content has ${2 + 1} lines
  '';
in
...

Need to actually type a $? You escape it with.. another two '', cause why not. Here’s the same thing with the expression escaped, meaning it will be part of the content literally.

let
  content = ''
    muli
    line
    this content has ''${2 + 1} lines
  '';
in
...

Functions

A lambda that takes a single parameter and increases it by 1 looks like this.

first: first + 1
  # => «lambda @ «string»:1:1»

Call it by putting its args just after the function. You don’t need parens to call it.

let f = first: first + 1;
in f 1
  # => 2

Or call it directly - yeah, this one’s a little weird - and you need parens to let it know where the function ends, or you could bind it to a variable.

(first: first + 1) 1
  # => 2

A function that takes an attribute set looks like this. It destructures the attribute set into local variables a and b in one go.

{ a, b }:
let result = a + b;
in result
  # => «lambda @ «string»:1:1»

Call it by putting an attribute set after it.

({ a, b }:
  let result = a + b;
  in result) { a = 1; b = 1; }
  # => 2

Domain specific

The language is a domain specific one, which means it also comes with a bunch of builtins - functions - to talk to the nix store, manage files and derivations.

This shows an example that can be used to create a config file with the provided content. The file will be stored in the /nix/store/ and the called function will return the file path.

let
  gh-config =
    builtins.toFile "config-file.yml" ''
      aliases:
        co: pr checkout
      editor: vim
      git_protocol: https
      version: '1'
    '';
in
...

Use of these builtins are often found in other pkgs’ derivations, in more specialized forms, or in modules like those provided by home-manager.

One such example is pkgs.writeShellScript, that can be used to wire together runnable shell scripts that references pkgs that nix pulls in.

The following results in two aliases available for all users (using the environment module), that reference a shell script with the content provided, that use the installed packages curl and jq. The aliases call the shell script with a parameter $1 denoting the city.

environment.systemPackages = with pkgs; [curl jq];
environment.shellAliases = let
  script = pkgs.writeShellScript "weather.sh" ''
    ${pkgs.curl}/bin/curl -s "https://wttr.in/$1?format=j1" |\
      ${pkgs.jq}/bin/jq ".current_condition | \"$1: \(.[].temp_C)°C, feels like \(.[].FeelsLikeC)°C\""
  '';
in {
  wo = "${script} oslo";
  wt = "${script} trondheim";
};

The shell script referenced by the aliases is installed in the /nix/store/, with its name taken from a hash of the script contents combined with the provided filename. The script is readonly and not editable after it has been installed by nix, like everything in else the /nix/store/.

> alias wt wo
wt='/nix/store/vkz9vsxyqc1nx2zyaharxcw715i4f2zx-weather.sh trondheim'
wo='/nix/store/vkz9vsxyqc1nx2zyaharxcw715i4f2zx-weather.sh oslo'

Things to note;

  • ${code} in curly brackets, reference nix-code, when nix runs.
  • $1 references the bash variable, when the shell script runs.
> wt
"trondheim: -5°C, feels like -8°C"
> wo
"oslo: -20°C, feels like -24°C"

Lazy

The language is also lazy, which means the computation of the values of an attribute set are not evaluated until they are needed.

So something like this works fine, instantly.

{
  a = 1;
  b = thisReallySlowFunction 2;
}.a
  # => 1

Imports and shared code

If you put the function in a file, you can pass parameters to it as you import it. This function takes an attribute set with the keys a and b as its parameters.

# function.nix
{ a, b }:
let result = a + b;
in result + 2

Then import it and all it in one go, like this.

1 + import ./function.nix {
  a = 1;
  b = 1;
}
  # => 5

With

Careful with this one; the keyword with is pretty much like the one from javascript. It prevents repetition where you need to repeatedly look stuff up from the same attribute set - and makes it really hard to see where stuff comes from, so keep variables you refer from it close to its use.

It is nice e.g. when installing packages with home-manager.

Instead of this.

home.packages = [
  pkgs.git
  pkgs.curl
  pkgs.wget
];

You can do this.

home.packages = with pkgs; [
  git
  curl
  wget
];

Everything is merged!

Because you like small, self-contained modules, reusable across systems, you prefer splitting code out into separate modules! Nix handles modification of the same paths of an attribute set across modules by merging them when it is producing the final result.

(This is probably more a property of modules and home-manager, than the language itself)

E.g. with two home-manager modules git.nix and terminal.nix.

# home/git.nix
{
  home.packages = with pkgs; [ git ];
}
# home/terminal.nix
{
  home.packages = with pkgs; [ alacritty foot kitty ];
}

And the modules registered with home-manager.

# home/default.nix
{ inputs, pkgs, ... }: {

  imports = [
    ./git.nix
    ./terminal.nix
  ];

  ...

}

Their content will be merged, such that all the packages from both modules are installed. You can think about it ending up something like this.

{
  home = {
    packages = [ pkgs.git pkgs.alacritty pkgs.foot pkgs.kitty ];
  };
}

..alongside a bunch of other stuff. This merged result in particular would probably be a tiny part of the resulting attribute set once all modules are combined into a single result.

And this is really what the language Nix is all about - building an attribute set that explains what the resulting system should look like. How it is done? That is up to the rest of Nix to decide.