Writing and Using Nix Modules
Anthony OleinikLook at the following documentation page. It is beautiful. Hundreds of options all well-documented and explained, with examples, defaults, and source code locations.
This is the beauty of the nix module system. Projects define a module system; then, anyone can write a module for that system and plug right in - first or third party. It’s a recipe for large-scale and maintainable systems. For instance, the wonderful nix-index-database defines a home-manager
module. We can plug it into our dotfiles and use it!
Projects often define their own module system, like we just saw with home-manager
. Another popular project that allows us to define modules is flake parts, a library that splits your flake up into multiple, smaller parts - and gives you more control into the components that you “plug in” to your flake.nix
.
What is a module, and how do we use it?
A module plugs into a module system to expose a new set of features. In the following example, we’ll use the treefmt-nix
module and plug it into a flake-parts
module system to expose some formatting features.
Perhaps I have a big project that’s written in terraform
- terraform format
works for me for the most part, but now I want to lint my markdown files as well. I could stuff my flake.nix
with tree-fmt
:
{
inputs.treefmt-nix.url = "github:numtide/treefmt-nix";
outputs = { self, nixpkgs, systems, treefmt-nix }:
let
eachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
treefmt = eachSystem (pkgs: {
projectRootFile = "flake.nix";
programs.terraform.enable = true;
programs.markdown.enable = true;
});
in
{
formatter = eachSystem (pkgs: treefmt.${pkgs.system}.config.build.wrapper);
checks = eachSystem (pkgs: {
formatting = treefmtEval.${pkgs.system}.config.build.check self;
});
};
}
This works well, but after a flake gets large it begins to get really, really messy - and besides importing files, there’s no “vanilla” way of splitting this up.
Instead, we can do the following:
{
inputs = {
flake-parts.url = "github:hercules-ci/flake-parts";
treefmt-nix.url = "github:numtide/treefmt-nix";
};
outputs = inputs:
inputs.flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" ];
imports = [
inputs.treefmt-nix.flakeModule
./modules/treefmt.nix
];
};
}
most “module consumers” will specify an imports
attribute, which allow you to load in different modules. In this example, we load in the treefmt-nix
module - this tells flake-parts
that we want to expose the features of treefmt-nix
. That is, treefmt-nix.flakeModule
is a template of sorts and lets us configure it.
and then, in ./modules/treefmt.nix
:
{ inputs, ... }:
{
perSystem = {}: {
treefmt = {
programs.terraform.enable = true;
programs.markdown.enable = true;
};
};
}
So the treefmt-nix.flakeModule
exposed a treefmt.programs.terraform.enable
option, and we configured it in this module.
What a useful abstraction! we can now put as much treefmt
specific stuff in that file without worrying that we will get stuff tangled.
Typically, projects will expose some sort of module-system plugin; whether it be a hm-module
, a flake-module
, or a nixos-module
, modules tend to be a very easy way to consume external nix code.
Writing a Module
So what does it take to produce a nix flake module? Remember: everything in nix boils down to attribute sets - so a module is an attribute set that conforms to a specific format.
In this example, we will write a flake-parts
module - the modules for all module systems consume the same-ish API, so if you’re after a tutorial for writing e.g. a nixOS
module, this is pretty much the same thing.
Lets get some details: at the end of the day, we need to expose something to the user - for example, in nixOS
, we can e.g. write a module that exposes a nice API for configuring a systemd service. The module provider will give you the tools to do things, and your module is an API around it.
Lets say we want to have a nice API for some package we are creating - we want to configure it in a flake-parts
-y way. First, we need to have some way for the user to consume our module. The easiest way is to write a flake and publish it on github - or something like flakehub, a kind of registry for flake modules.
Lets write a package that takes a string and a number, and then outputs it to stdout that number times. We want the user, the consumer of the flake, to say something like:
{
string = "some string";
times = 3;
}
and then when we execute the binary for our derivation, we echo <some stringsome stringsome string>
to stdout! Very useful, I know.
lets start by writing that flake we mentioned earlier:
{
description = "String repeater";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
outputs = {
self,
nixpkgs,
}: let
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in {
flakeModule = import ./module.nix;
};
}
ta-da! now, if we run nix flake show
, we see something like:
nix flake show .#
git+file:///some/dir
└───flakeModule: unknown
lets create that ./module.nix
file now.
{ lib, flake-parts-lib, ... }: {
options = {
perSystem = flake-parts-lib.mkPerSystemOption
({ system, pkgs, ... }: { options = { }; });
};
}
flake-parts
exposes a nice flake-parts-lib
library with useful features like mkPerSystemOption
. If you’re unfamiliar with flake parts, per-system
is the way that flake-parts
nicely abstracts around having to specify system
everywhere. It automatically applies the system
parameter to these options, meaning any system we define in flake.nix
is useable.
Lets define our options. First, we want to be nice to our neighbors and tuck away this option set into a nested one; that way the user isn’t setting repeat = 3
on the global scope, which means literally nothing to most readers; we want them to have to set string-repeater.repeat = 3
.
{ lib, flake-parts-lib, ... }: {
options = {
perSystem = flake-parts-lib.mkPerSystemOption ({ system, pkgs, ... }: {
options = {
string-repeater = lib.mkOption {
type = lib.types.submoduleWith {
modules = [
({ config, ... }: {
options = with lib; {};
})
];
};
};
};
});
};
}
what does that say? We defined a sub-module called string-repeater
. the type of the submodule is… well, a submodule! Either we define some primitive, or a submodule: any “nested attribute” is a submodule. We do this because we want to logically group together options; you may go as deep as you’d like with submodules; my.sub.module.that.you.plug.in.enable = true
is perfectly valid, given you define submodule types all the way down. It’s polite to namespace options; if we just had a top-level repeat
option, that would be very rude. Instead, we want to define string-repeater.repeat
.
Lets first add the repeat
and times
part that we talked about:
{ lib, flake-parts-lib, ... }: {
options = {
perSystem = flake-parts-lib.mkPerSystemOption ({ system, pkgs, ... }: {
options = {
string-repeater = lib.mkOption {
type = lib.types.submoduleWith {
modules = [
({ config, ... }: {
options = with lib; {
string = mkOption {
type = types.str;
description = mdDoc "The string to repeat";
example = "hello world!";
};
times = mkOption {
type = types.int;
default = 1;
description = mdDoc "number of times to repeat the string";
};
};
})
];
};
};
};
});
};
}
And we have the options defined! We can now use them to create the package that we need. Notice how the structure of this module is pretty similar to what we want? It follows the {string = "hello world!"; times = 5; }
pattern.
So how do we use our options? well, at the top level, we configure an options
; we can also configure a config
; the config
can set values on our options. Why would we want to do that? Because we can set a option as output only! This means that the user can read from the option but never set to it, which is exactly what we want.
{ lib, flake-parts-lib, ... }: {
options = {
perSystem = flake-parts-lib.mkPerSystemOption ({ system, pkgs, ... }: {
options = {
string-repeater = lib.mkOption {
type = lib.types.submoduleWith {
modules = [
({ config, ... }: {
options = with lib; {
string = mkOption {
type = types.str;
description = mdDoc "The string to repeat";
example = "hello world!";
};
times = mkOption {
type = types.int;
default = 1;
description = mdDoc "number of times to repeat the string";
};
package = mkOption {
type = types.package;
description = mdDoc "Final package for string ";
readOnly = true;
};
};
})
];
};
};
};
});
};
}
Now lets use that config
block that we talked about:
{ lib, flake-parts-lib, ... }: {
options = {
perSystem = flake-parts-lib.mkPerSystemOption ({ system, pkgs, ... }: {
options = {
string-repeater = lib.mkOption {
type = lib.types.submoduleWith {
modules = [
({ config, ... }: {
options = with lib; {
string = mkOption {
type = types.str;
description = mdDoc "The string to repeat";
example = "hello world!";
};
times = mkOption {
type = types.int;
default = 1;
description = mdDoc "number of times to repeat the string";
};
package = mkOption {
type = types.package;
description = mdDoc "Final package for string ";
readOnly = true;
};
};
config = with lib; {
package = pkgs.writeShellScriptBin "string-repeater" ''
yes ${config.string} | head -${builtins.toString (config.times)}
'';
};
})
];
};
};
};
});
};
}
Using Our Own Module
Here’s a flake that consumes that packageIt just imports the module like a path instead of going through the flake:
{
inputs = {
flake-parts.url = "github:hercules-ci/flake-parts";
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
};
outputs = inputs:
inputs.flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" ];
imports = [
../module.nix
];
perSystem = {
config,
pkgs,
...
}: {
string-repeater = {
times = 2;
string = "hello world!";
};
devShells.default = pkgs.mkShell {
packages = [ config.string-repeater.package ];
};
};
};
}
running nix develop .#
and then string-repeater
yields:
hello world!
hello world!
It worked! We wrote a nix module! Publishing is out of scope, but I’ve seen people use a github action to publish to flakehub. Otherwise, if you push to github, you can use your module in the inputs
section, similar to how we’re importing flake-parts
above.
I’d suggest flakehub, because you get a nice documentation page, but github is easier - especially for your first flake.