Problems with types.oneOf and submodules

I’m trying to define a NixOS module with an option that has a type like this:

    test = mkOption {
      type = types.oneOf [
        (types.submodule {
          options = {
            type = mkOption {
              type = types.enum [ "a" ];
            };
            a1 = mkOption {
              type = types.int;
            };
            a2 = mkOption {
              type = types.int;
            };
          };
        })
        (types.submodule {
          options = {
            type = mkOption {
              type = types.enum [ "b" ];
            };
            b1 = mkOption {
              type = types.int;
            };
            b2 = mkOption {
              type = types.int;
            };
          };
        })
      ];
    };

but when I try to provide a value for this like:

    test = {
      type = "b";
      b1 = 3;
      b2 = 3;
    };

it says that the option test.b1 does not exist. However this does work:

    test = {
      type = "a";
      a1 = 3;
      a2 = 3;
    };

Also, if I don’t use two submodules but instead a primitive and a submodule like:

    test = mkOption {
      type = types.oneOf [
        types.int
        (types.submodule {
          options = {
            type = mkOption {
              type = types.enum [ "b" ];
            };
            b1 = mkOption {
              type = types.int;
            };
            b2 = mkOption {
              type = types.int;
            };
          };
        })
      ];
    };

then a configuration like

    test = {
      type = "b";
      b1 = 3;
      b2 = 3;
    };

does work!

So it seems like types.oneOf has problems dealing with multiple submodule options with different sets of attributes. Is there any way around this? Or some other approach to representing mutually exclusive sets of submodule options?

4 Likes

I just had the same issue and found this post. After a couple hours of digging through the sources of types.nix I think I understand what’s happening. There’s two pertinent points where it goes wrong.

First the merge function of either / oneOf at

merge = loc: defs:
  let
    defList = map (d: d.value) defs;
  in
  if all (x: t1.check x) defList
  then t1.merge loc defs
  else if all (x: t2.check x) defList
  then t2.merge loc defs
  else mergeOneOption loc defs;

It checks whether all defs that are to be merged are either of the left type (t1) or the right type (t2). This makes sense since if we’re merging we only want to have one side of the either, presumably else the merging is probably not well defined.

Now if submodules come into play inside the either we’ll have to look at how those do check at:

check = x: isAttrs x || isFunction x || path.check x;

This is where it breaks all appart if both your type parameters to either are submodules because isAttrs is going to be true thus the merge in either will always think it’s the left type parameter (t1).

I hacked together a solution that seems to work so far but I only just got done so I don’t know yet if it’s just gonna break other things but here it is: A hacky fix for the either submodule problem · GitHub

3 Likes

Ran into this myself recently - @jmbaur found an upstream example where you can induce eval failure because of this issue so I opened an issue for it: Eval failure: postgrey socket options · Issue #263479 · NixOS/nixpkgs · GitHub

I started working on upstreaming a type to allow something like oneOf inside an attrset/list lib/types: init taggedSubmodule by Lassulus · Pull Request #254790 · NixOS/nixpkgs · GitHub currently I use something like that already in disko

4 Likes

I’ve made this thing mostly for fun. I don’t recommend you actually use it.

# typecheckSubmoduleByTryEval :: Submodule -> Submodule
typecheckSubmoduleByTryEval = submodule: let
  check = x: (builtins.tryEval ((lib.evalModules {
    modules = submodule.getSubModules ++ [ x ];
  }).config)).success;
in lib.types.addCheck submodule check;

If you apply it to a submodule, it will try to eval the module with the value given as a part of the typecheck. It lets you skip over the submodule in an oneOf or either if it doesn’t seem to fit. With a big downside where any typo or small mistake might lead to errors that can be very hard to debug, because it skipped past the module and now throws type errors for a different part of the oneOf/either expression.

{
  options = {
    nestedTreeOfStuff = lib.mkOption {
      type = let
        leafNodeSubmodule = lib.types.submodule {
          options = {
            x = lib.mkOption { type = lib.types.int; };
            y = lib.mkOption { type = lib.types.bool; };
            z = lib.mkOption { type = lib.types.str; };
          };
        };

        treeType = let
          leafOrBranch = lib.types.oneOf [
            (typecheckSubmoduleByTryEval leafNodeSubmodule)
            (attrsOf leafOrBranch)
          ];
        in leafOrBranch;
      in treeType;
    };
  };

  config.nestedTreeOfStuff = {
    a.b.c = {
      x = 1;
      y = false;
      z = "hello :)";
    };

    q.w.e.r.t.y = {
      x = 2;
      y = true;
      z = "world :3";
    };
  };
}

This problem is not as straightforward as it seems, and I implemented a complex strategy to solve it many years ago, but in Haskell. It might be worth referencing, and the code is here: algebraic-json-spec/src/JsonSpec/Core/Functions.hs at 1d4bcfd7081c9557fe08cf60dad2ebcb18000d9f · luochen1990/algebraic-json-spec · GitHub

How does this relate to the NixOS module system?