2 09 Nix Language
Nimmo edited this page 2026-05-14 07:54:37 +01:00

Chapter 9: The Nix Language

Before you can understand or modify your NixOS configuration, you need to be able to read and write the Nix expression language. This chapter covers everything you will encounter in this repository.

Nix is a purely functional language. There are no variables that change, no loops, and no side effects. You write expressions that evaluate to values. That is all.

Basic Data Types

Strings

"hello world"

Multi-line strings use double single-quotes:

''
  This is a multi-line string.
  Leading whitespace is stripped intelligently.
''

String interpolation uses ${}:

"Hello, ${name}"

You will see this in your config. For example, in a profile module:

inputs.claude-code-nix.packages.${pkgs.stdenv.hostPlatform.system}.claude-code

Here ${pkgs.stdenv.hostPlatform.system} evaluates to something like "x86_64-linux" and gets inserted into the attribute path.

Numbers

42
3.14

You will rarely use numbers directly. One place they appear is in byte values like nix-settings.nix:

min-free = 5368709120;  # 5GB in bytes

Booleans

true
false

These appear constantly:

services.xserver.enable = true;
hardware.nvidia.open = true;

Null

null

Represents the absence of a value.

Paths

./hardware.nix
../common
../../modules/desktop/plasma.nix

Paths are a first-class type in Nix. They are not strings. When Nix evaluates a path, it resolves it relative to the file containing it. This is why your imports work with relative paths.

Lists

[ "nvme" "xhci_pci" "thunderbolt" ]

Lists are ordered. Items are separated by spaces, not commas. From your hardware-configuration.nix:

boot.initrd.availableKernelModules = [ "nvme" "xhci_pci" "thunderbolt" "usbhid" "usb_storage" "sd_mod" ];

Attribute Sets (attrsets)

The most important data type in Nix. An attrset is a collection of key-value pairs:

{
  name = "electra-dgpu";
  cores = 6;
  enable = true;
}

Attrsets are delimited by { } and each assignment ends with ;. This is the structure that makes up your entire NixOS configuration.

Nested attrsets

You can nest them:

{
  hardware = {
    bluetooth = {
      enable = true;
      powerOnBoot = true;
    };
  };
}

Or use dot notation as a shorthand:

{
  hardware.bluetooth.enable = true;
  hardware.bluetooth.powerOnBoot = true;
}

Both forms are identical. Your configuration mixes both styles freely. For example, hosts/common/default.nix uses dot notation:

boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
networking.networkmanager.enable = true;

While modules/hardware/bluetooth.nix uses the nested form:

hardware.bluetooth = {
  enable = true;
  powerOnBoot = true;
  settings = {
    General = {
      Experimental = true;
    };
  };
};

Recursive attrsets

Prefixing with rec lets attributes reference each other:

rec {
  x = 1;
  y = x + 1;  # y = 2
}

You will not see this in your configuration, but you should know it exists.

Functions

Nix functions take exactly one argument. They look like this:

x: x + 1

The part before the colon is the argument. The part after is the body.

Attrset destructuring

Most functions in NixOS configuration take an attrset argument and destructure it:

{ config, pkgs, ... }:

This means: "I accept an attrset. Bind its config attribute to the name config, its pkgs attribute to pkgs, and ignore everything else (...)."

Every single .nix file in your configuration is a function with this shape. For example, modules/common/system.nix:

{ config, pkgs, ... }:

{
  # ... configuration ...
}

The function receives the attrset, and returns an attrset (the configuration). NixOS calls this function, passing in config, pkgs, and many other things.

The ... (ellipsis)

The ... means "there might be more attributes in this attrset that I do not care about." Without it, Nix would throw an error if it passed extra attributes.

Extra arguments: inputs

Some of your files accept inputs:

{ config, pkgs, inputs, ... }:

This works because inputs is passed through specialArgs in your flake.nix. We will cover this in detail in Chapter 13.

Key Language Constructs

with expression

with pkgs; [ firefox git wget ]

This brings all attributes of pkgs into scope so you do not have to write pkgs.firefox, pkgs.git, pkgs.wget. You see this in modules/common/system.nix:

environment.systemPackages = with pkgs; [
  btop
  htop
  git
  wget
  # ...
];

Without with, you would write:

environment.systemPackages = [
  pkgs.btop
  pkgs.htop
  pkgs.git
  pkgs.wget
];

inherit keyword

inherit copies a name from the enclosing scope into an attrset:

let
  x = 1;
  y = 2;
in {
  inherit x y;
  # equivalent to: x = x; y = y;
}

You see this in flake.nix:

specialArgs = { inherit inputs; };
# equivalent to: specialArgs = { inputs = inputs; };

let ... in expression

Defines local bindings:

let
  name = "electra-dgpu";
  greeting = "Hello, ${name}";
in
  greeting
# evaluates to "Hello, electra-dgpu"

You see this in modules/services/nixos-auto-update.nix:

let
  cfg = config.services.nixos-auto-update;
  updateScript = pkgs.writeShellScript "nixos-auto-update" ''
    ...
  '';
in
{
  options.services.nixos-auto-update = { ... };
  config = mkIf cfg.enable { ... };
}

let bindings are only visible within the in block.

if ... then ... else

if x > 0 then "positive" else "non-positive"

Note: if is an expression, not a statement. It always produces a value. Both branches are required.

import

import reads a Nix file and evaluates it:

import ./some-file.nix

If the file contains a function (which your NixOS modules do), import returns that function. NixOS then calls it with the appropriate arguments.

@ pattern

You will see this in flake.nix:

{ self, nixpkgs, nixpkgs-stable, ... }@inputs:

This destructures the argument and binds the entire attrset to inputs. So you can use nixpkgs directly and also pass the whole inputs set to other things.

The lib Library

NixOS provides a large standard library called lib. You will see functions from it throughout your config. Key ones:

lib.mkForce

Overrides a value that was set elsewhere, forcing it to take priority:

security.pam.services.login.fprintAuth = lib.mkForce true;

lib.mkDefault

Sets a value with low priority, so other modules can override it:

nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";

lib.mkIf

Conditionally includes configuration:

config = mkIf cfg.enable {
  # This configuration only applies when cfg.enable is true
};

lib.mkEnableOption

Creates a boolean option that defaults to false:

enable = mkEnableOption "automatic NixOS updates";

lib.mkOption

Creates an option with a type, default, and description:

flakePath = mkOption {
  type = types.str;
  default = config.nixosConfig.flakeRepo;
  description = "Path to the NixOS configuration flake";
};

These last three (mkIf, mkEnableOption, mkOption) are used when writing modules with custom options, which we cover in Chapter 12.

Reading Your Configuration Files

Now you should be able to read any file in your configuration. Let us walk through hosts/common/default.nix as a complete example:

{ config, pkgs, inputs, ... }:       # Function that takes config, pkgs, inputs

{                                     # Returns an attrset (the configuration)
  imports = [                         # List of other modules to include
    inputs.disko.nixosModules.disko   # Attribute path into the disko flake input
    ./nix-settings.nix                # Relative path to a file
    ./locale.nix
    ../../modules/common/default-config.nix
    ../../modules/common/sops-host.nix
    ../../modules/maintenance/garbage-collection.nix
    ../../modules/maintenance/btrfs-maintenance.nix
  ];

  boot.loader.systemd-boot.enable = true;   # Dot notation to set nested values
  boot.loader.efi.canTouchEfiVariables = true;

  networking.networkmanager = {              # Nested attrset form
    enable = true;
    wifi.powersave = true;                   # Dot notation inside a nested block
    settings = {
      connectivity = {
        enabled = false;                     # Disable captive portal detection
      };
    };
  };
}

Every concept used here was covered in this chapter. The entire file is a function that takes some arguments and returns an attrset of configuration. Notice how it mixes dot notation (boot.loader.systemd-boot.enable) with nested attrsets (networking.networkmanager = { ... }).