A library usually consists of two parts: the implementation, which implements the public interface people are able to use, and development, which can be anything from testing to deployment.

It is very common for the development part to have its own dependencies in addition to what the implementation part requires, things like testing frameworks and formatters.

The Problem

Some package managers allow users to specify development-only dependencies, for example, Cargo (Rust’s package manager) supports this with the dev-dependencies table:

[dependencies]
serde = "1" # this is for the implementation

[dev-dependencies]
insta = "1" # this is for development only

Something like this has been proposed for Nix flakes, but it is unsupported due to limitations of the flake schema:

We cannot have dev-only inputs without dev-only outputs […] we would need a devOutputs function in addition to outputs

Unlike traditional programming languages, Nix is almost designed for development. While Cargo’s dev-dependencies is usually only used for testing. Nix is able to do a lot more: testing, formatting, linting, git hooks, containers, MicroVMs… There are even frameworks to help unify all these tools, just because there are so many of them, not to mention all the Nix libraries just to help you write Nix.

For Nix, there can be a lot more dependencies for development, or in Nix terms, flake inputs, but no way to make them development-only without any workarounds.

Subflakes

Subflakes is a rather undocumented pattern of Nix flakes. By having a separate flake in a subdirectory, the subflake is able to access its parent directory’s contents with ../.*.

We can put the development-only flake inputs in the subflake, and dependent flakes will not get these dependencies in their flake.lock.

There is one issue - using ../. only gives you a path, but not the flake contents. In the subflake, you can add the parent flake as a flake input:

inputs.parent.url = "../.";`,

But by doing that, every time anything changes, the subflake’s flake.lock would be updated, which is not very ideal, and builtins.getFlake doesn’t work for a similar reason.

There are things proposed to fix this, but for now we have to work around it, and yes I am suggesting workarounds for a workaround, but this is what worked the best for me.

The solution is to “reimplement” the flakes logic in Nix (the language). There are existing libraries that do this, so you don’t have to implement it yourself:

Example

I will be using call-flake here for demonstration since it just copies from upstream Nix, together with namaka for testing and flake-parts to make my life easier. get-flake should work just as well, and you don’t need either namaka or flake-parts.

Let’s start by creating a git repository and a simple flake.nix:

{
  outputs = { self }: {
    lib = {
      answer = 42;
      double = x: x * 2;
    };
  };
}
$ nix eval .#lib
{ answer = 42; double = <LAMBDA>; }

We can create a subflake in the dev directory:

# dev/flake.nix
{
  inputs = {
    flake-parts = {
      url = "github:hercules-ci/flake-parts";
      inputs.nixpkgs-lib.follows = "nixpkgs";
    };
    namaka = {
      url = "github:nix-community/namaka/v0.2.0";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
  };

  outputs = inputs@{ flake-parts, namaka, ... }:
    flake-parts.lib.mkFlake { inherit inputs; } {
      systems = [
        "aarch64-darwin"
        "aarch64-linux"
        "x86_64-darwin"
        "x86_64-linux"
      ];

      perSystem = { inputs', pkgs, ... }: {
        devShells.default = pkgs.mkShell {
          packages = [
            inputs'.namaka.packages.default
          ];
        };
      };
    };
}

If you are not familiar with flake-parts, this essentially creates a dev shell with namaka’s CLI. We can enter it with nix develop ./dev:

$ namaka --version
namaka 0.2.0

Now we can start implementing the tests. We can use call-flake here to import our flake into the subflake:

# add this to inputs
call-flake.url = "github:divnix/call-flake";

and we can make namaka load the tests from the tests directory:

# add this to outputs
flake.checks = namaka.lib.load {
  src = ./tests;
  inputs = {
    foo = (call-flake ../.).lib;
  };
};

Now we can create the tests to make sure 42 * 2 is always 84:

mkdir -p dev/tests/works
echo "{ foo }: foo.double foo.answer" > dev/tests/works/expr.nix

Namaka is a snapshot testing library, meaning you don’t need to write 84 yourself, and the namaka CLI will do this for you.

Always typing out dev can be annoying, so we can configure namaka to always work with the dev directory. At the root of git repository, create a namaka.toml with the following contents:

[check]
cmd = ["nix", "eval", "./dev#checks"]
# or
# cmd = ["nix", "flake", "check", "./dev"]

[eval]
cmd = ["nix", "eval", "./dev#checks"]

Run namaka check then namaka review to update the snapshots. Make sure the newly created files are added to git.

Now if we run namaka check again, all the tests should be passing.

✔ works
All 1 tests succeeded

And now we get to use libraries like namaka and flake-parts for development without having to worry about these dependencies being propagated to our users!


A modified version of this example is also available as a template, so you can start using subflakes for development with just one command:

nix flake init -t github:nix-community/namaka#subflake

Drawbacks

  • With flakes, ../. only works if you are in a git repository, so things like nix flake check path:dev wouldn’t work.

  • You would have to append ./dev to your commands. namaka makes this easier by allowing a config file, but you still have to deal with this with commands like nix develop and nix flake check.

  • You need to keep track of two flake.lock files. This can make updating slightly more cumbersome.

Discuss on NixOS Discourse