How Can I Remove Boilerplate in NixOS Modules That Only Differ by a Few Variables?

In my configurations I came across a few cases where after I did some refactoring I got a multiple modules with the exact same structure, with the only difference being a variable or two.

For example I have created a modules representing each of my hosts that run syncthing. So module for work-pc will look

{ config, lib, ... }:
let
  deviceName = "work-pc";
  cfg = config.custom.services.syncthing.remoteDevices.${deviceName};
in
{
  options.custom.services.syncthing.remoteDevices.${deviceName} = {
    enable = lib.mkEnableOption "Enable Syncthing device: homelab";
    name = lib.mkOption {
      type = lib.types.str;
      default = deviceName;
      description = "Device name of ${deviceName}";
    };
  };

  config = lib.mkIf cfg.enable {
    services.syncthing = {
      settings = {
        devices = {
          "${cfg.name}" = {
            id = "id_1"
            autoAcceptFolders = true;
            name = cfg.name;
            introducer = false;
          };
        };
      };
    };
  };
}

while a module for homelab will look the same but will use different deviceName(homelab) and id.

{ config, lib, ... }:
let
  deviceName = "work-pc";
  cfg = config.custom.services.syncthing.remoteDevices.${deviceName};
in
{
  options.custom.services.syncthing.remoteDevices.${deviceName} = {
    enable = lib.mkEnableOption "Enable Syncthing device: homelab";
    name = lib.mkOption {
      type = lib.types.str;
      default = deviceName;
      description = "Device name of ${deviceName}";
    };
  };

  config = lib.mkIf cfg.enable {
    services.syncthing = {
      settings = {
        devices = {
          "${cfg.name}" = {
            id = "id_2";
            autoAcceptFolders = true;
            name = cfg.name;
            introducer = false;
          };
        };
      };
    };
  };
}

My question is what is the nix idiomatic way to element all this boiler plate?

Generally, when you’re going around defining custom options for things like this you’re probably doing it wrong. I’d suggest something like this:

# syncthing.nix
{
  services.syncthing.settings.devices = let
    defaultSettings = {
      autoAcceptFolders = true;
      introducer = false;
    };
  in {
    work-pc = defaultSettings // {
      id = "id_1";
    };
    homelab = defaultSettings // {
      id = "id_2";
    };
  };
}

Note that the name is already set to the attrset key, so you don’t have to redundantly repeat the name.

If the repeated defaultSettings // annoys you you could write about 200 extra lines of confusing submodule redaclaration to deduplicate that, too, and obfuscate the behavior of the upstream module across your codebase, but I think I prefer the really obvious code here.

That said, I don’t understand why you’re writing a custom option that basically just clones an existing option, so maybe I’m missing something that restricts you here.


This is a special case because the attrset keys refer to the hosts involved in the config, though.

The general case is simply to define generic things in a generic module, and then to change specific settings in host-specific modules. As an example, say you wanted to have syncthing enabled generally, but every host has a different syncthing user for some reason:

# syncthing.nix
{
  services.syncthing = {
    enable = true;
    group = "generic";
    # ...
  };
}
# host1.nix
{
  # Note that you can import modules that import other
  # modules, and the merge still works recursively, so
  # this could be a generic `configuration.nix` that simply
  # imports `syncthing.nix` in turn
  imports = [ ./syncthing.nix ];

  services.syncthing.user = "user1.nix";
}
# host2.nix
{
  imports = [ ./syncthing.nix ];

  services.syncthing.user = "user2.nix";
}

You can even specifically override settings:

# host3.nix
{ lib, ... }: {
  imports = [ ./syncthing.nix ];

  services.syncthing = {
    user = "user2.nix";
    group = lib.mkForce "somethingelse";
  };
}

Structuring config like this appears to be counterintuitive to people at first (I think because generally people think of the generic thing as the entrypoint, not the host config), but it really makes custom options like yours look awkward.

I consider this style idiomatic (and it definitely fits better with how the module system is designed), hence my consideration of custom options as a code smell. Custom options should generally be used to create new functionality, not to try and slightly restructure existing structured options.

1 Like

@TLATER Thanks for your detailed reply. Had no idea you could do that .

I have found an implementation that I like , which is essentially use it like a generic function with arguments.

So in general terms I created a module file(my-module.nix) with vars argument

{ vars }
{

  networking.hostName = vars.hostName;
  };
}

and then while importing it I can provide this variable

let
  myVars = {
    hostName = "my-server";
    domain = "example.com";
  };
in
{
  imports = [
    (import ./my-module.nix { vars = myVars; })
  ];
}

And more specifically to my syncthing module I do.

let
  cfg = config.custom.services.syncthing;
  syncthingDevices = [
    { name = "work-pc"; id = "id_1"; }
    { name = "home-desktop"; id = "id_2"; }
    { name = "homelab"; id = "id_3"; }
    { name = "kvm-nixos-server"; id = "id_4"; }
  ];

  deviceImports = builtins.map (device: import ./devices/defaul.nix {
    inherit config lib;
    name = device.name;
    id = device.id;
  }) syncthingDevices;
in

{
  # Each machine switches in the devices and folders it needs.
  imports = deviceImports ++ [
    ];

Your implementation should also do the trick but not in the case where I am defining options., And speaking of option

I should provide some context to what I am doing. I recently refactored my entire repository to the “import all and enable” pattern. The goal was to have all my custom modules/configurations to be imported by all of my hosts regardless of if they will be using them or not and then enable the once the host needs. I wanted to achieve the same implementation I have with the nixpkgs modules, E.g:

services.syncthing.enable = true;   

But for my custom modules/configurations. So I would import my foo.nix once into common file that is imported by all of my hosts and then just do

custom.services.foo.enable = true;

My goal was to use the same pattern for syncthing devices and folders. Meaning my hosts will import all of my devices and folders and then use enable to get the desired state.

So some models already have enable option, like bat, meaning I can write my config for it and just wrap it

{ config, lib, ... }:
{
  config = lib.mkIf config.programs.bat.enable {
    programs.bat = {
      config = {
        theme = "Visual Studio Dark+";
        wrap = "character";
        terminal-width = "80";
      };
    };
  };
}

But options like syncthing devices and folders

services.syncthing.settings.devices.<name>
services.syncthing.settings.folders.<name>

do not, meaning I need to add to then an option to enable them.

After your input, I did looked on both my folders.nix and devices.nix modules and removed one of the options form devices(so it only has the enable option now).

But I disagree with you about “not to try and slightly restructure existing structured options” As a developer will gladly wrap some generic function in another function that will do nothing more then providing better readability, by giving it an explicit name.

For example in the case on my folders.nix

{ config, lib, name, dirName, ... }:
let
  cfg = config.custom.services.syncthing;
  # name = "dev_resources";
  # dirName = "dev_resources";
in
{
  options.custom.services.syncthing.settings.folders.${name} = {
    enable = lib.mkEnableOption "Enable Syncthing folder: ${name}";
    devices  = lib.mkOption {
      default = [ ];
      type = lib.types.listOf lib.types.str;
      description = "List of devices to use for folder synchronization.";
    };
  };

  config = lib.mkIf cfg.settings.folders.${name}.enable {
    services.syncthing = {
      settings = {
        folders = {
          "${name}" = {
            id = "${name}";
            path = "${cfg.syncDir}/${dirName}";
            devices = cfg.settings.folders.${name}.devices;
            versioning = cfg.simpleFileVersioningForBackUpMachinesOnly;
          };
        };
      };
    };
  };
}

there is no real reason to define

options.custom.services.syncthing.settings.folders.${name}.devices

just to pass it to

services.syncthing.settings.folders.${name}.devices =  cfg.settings.folders.${name}.devices;

But then I won"t be to both enable a folder and set the devices it will be shared with under the same “namespace” like this

  custom = {

    services.syncthing = {
      settings = {
        devices = {
          homelab.enable = true;
          home-desktop.enable = true;
        };

        folders = {
          taskwarrior = {
            enable = true;
            devices = [
              "${config.services.syncthing.settings.devices.homelab.name}"
              "${config.services.syncthing.settings.devices.home-desktop.name}"
            ];
          };

        };
      };
    };
  };

and if I wanted I could go further and change

options.custom.services.syncthing.settings.folders.${name}.devices

To:

options.custom.services.syncthing.settings.folders.${name}.devicesToShareWith

And get even more readable text.

1 Like