An experimental Nix library that allows flakes to define independently scoped dependencies. poacher.dev/scoped-flakes

README.md

An experimental Nix library that allow flake outputs to define independently scoped dependencies.

The repository can be found on Tangled with a mirror on GitHub. The docs are hosted on poacher.dev.

How it works #

A flake calls lib.new on each output it wants independently scoped. lib.new resolves that call's declared inputs against our own lockfile rather than the flake's own dummy inputs, so two outputs of the same flake can have different dependencies without forcing a consumer to download them.

lib.new #

# `self` is curried here for convenience
lib.new self {
  # The unique key written to the lockfile
  name = "packages-nixpkgs"; 

  # optional, impurely defaults to `builtins.currentSystem`
  system = "x86_64-linux";   

  # All typical flake input options are valid here
  inputs.nixpkgs = {
    url = "github:nixos/nixpkgs?ref=nixos-unstable"; 

    # See "Overrides" below
    override = null;
  };

  # Mapped to `packages.x86_64-linux.hello`
  outputs = { self, nixpkgs }: {
    inherit (nixpkgs.legacyPackages.x86_64-linux) hello;
  };
}

Full example #

{
  inputs = {
    scoped-flakes.url = "github:poach3r/scoped-flakes";

    # Dummy inputs to populate flake.lock, never used directly 
    nixpkgs-unstable.url = "github:divnix/blank";
    nixpkgs-stable.url = "github:divnix/blank";
  };

  outputs = inputs: let
    inherit (inputs.scoped-flakes.lib) new overrideInput;

    # This is what I mean by convenience.
    ourNew = new inputs.self;
  in {
    # Scoped to unstable nixpkgs only.
    packages.x86_64-linux = ourNew {
      name = "packages-nixpkgs";

      inputs.nixpkgs = {
        url = "github:nixos/nixpkgs?ref=nixos-unstable";

        # Automatically uses our top-level nixpkgs-unstable when a consumer 
        # `follows` a real input.
        override = overrideInput inputs.nixpkgs-unstable;
      };

      outputs = { nixpkgs, ... }: {
        inherit (nixpkgs.legacyPackages.x86_64-linux) hello;
      };
    };

    # Scoped to stable nixpkgs only. A consumer who only uses `packages`
    # never pulls this one in.
    devShells.x86_64-linux = ourNew {
      name = "devshells-nixpkgs";

      inputs.nixpkgs = {
        url = "github:nixos/nixpkgs?ref=nixos-25.05";
        override = overrideInput inputs.nixpkgs-stable;
      };

      outputs = { nixpkgs, ... }: {
        default = nixpkgs.legacyPackages.x86_64-linux.mkShell { };
      };
    };
  };
}

A consumer of this flake can override just one of the two scoped dependencies:

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-25.05-small";
    project = {
      url = "github:bar/foo";
      inputs.nixpkgs-unstable.follows = "nixpkgs";
    };
  };

  outputs = { project, ... }: {
    apps.x86_64-linux = project.packages.x86_64-linux.default;
  };
}

Overrides #

new doesn't inspects your flake input to determine if its been overriden, however, we do provide helpers for you to write it yourself:

  • lib.isBlank input — true if input carries none of a real flake's outputs
  • lib.overrideInput input — null if isBlank input, else input

override = lib.overrideInput inputs.someDummyInput; is the usual shape: it'll pass null when the dummy is still blank, or the real resolved flake when a consumer has overridden it via follows.

lib.forAllSystems #

Calling new once per system can be cumbersome, so we provide a helper to makes scoped-flakes easier to use in multi-system flakes:

let
  forAllSystems = scoped-flakes.lib.forAllSystems { 
    inherit self; 

    # defaults to ["x86_64-linux" "aarch64-linux" "aarch64-darwin"]
    systems = ["x86_64-linux" "aarch64-linux"];
  };
in {
  packages = forAllSystems (system: {
    name = "packages-${system}";
    inputs.nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
    outputs = { nixpkgs, ... }: {
      inherit (nixpkgs.legacyPackages.${system}) hello;
    };
  });
}

The lockfile #

scoped-flakes.lock.json is committed to your repo just like your flake.lock. It's a two-level structure where entries.<name>.<input> is a narHash reference and resolved.<narHash> holds the actual pin. Don't edit this by hand please.

Updating pins #

# re-pin every call-site
nix run github:poach3r/scoped-flakes#update
# re-pin all inputs of a specific output
nix run github:poach3r/scoped-flakes#update -- --name packages-nixpkgs   
# re-pin a specific input
nix run github:poach3r/scoped-flakes#update -- --input sha256-MYiI+CzL0tuWgRPjGsKCDHqYs2T3OzMlMQWOYWG0qso=
# targets a specific path (default: .)
nix run github:poach3r/scoped-flakes#update -- .