How to declare a recursive tree type?

I’m currently working at making recursive bookmark directories in firefox with home-manager, and I’m currently stuck at the option type. Here’s an example of a configuration:

{
  wikipedia = {
    keyword = "wiki";
    url = "https://en.wikipedia.org/wiki/Special:Search?search=%s&go=Go";
  };
  "kernel.org" = {
    url = "https://www.kernel.org";
  };
  "Nix sites" = {
    homepage = {
      url = "https://nixos.org/";
    };
    wiki = {
      url = "https://nixos.wiki/";
    };
  };
}

And I’m defining the type as follows:

bookmarks = mkOption {
  type = let
    bookmarkType = types.submodule ({ config, name, ... }: {
      options = {
        keyword = mkOption {
          type = types.nullOr types.str;
          default = null;
          description = "Bookmark search keyword.";
        };
        url = mkOption {
          type = types.str;
          description = "Bookmark url, use %s for search terms.";
        };
      };
    });
    bookmarkDirectoryType = with types;
      attrsOf (either bookmarkType (bookmarkDirectoryType // { description = "self"; }));
  in bookmarkDirectoryType;
};

By overwriting the description attribute, we avoid the infinite recursion that occurs when the type description gets evaluated. However, this breaks because it doesn’t recognize the option bookmarks.bookmarks.Nix sites.homepage. Which I guess means that it doesn’t recurse, and thus can’t recognize the option?

I’ve also tried playing around with lazyAttrsOf without any luck. Am I reaching for the impossible, or am I just missing some crucial details? I’ve read through the option declaration manual, but couldn’t seem to find what I’m looking for.

A small update on the subject:

I found this post, which makes me think that I’m a victim of the same kind of problem. Before deciding whether the submodule instance is a bookmark or directory, it only checks that it’s either an attrset, function or a valid path and picks the first submodule. I could probably override the check function of the two types to check that it’s both an attrset and that they include certain non-null attributes (like url), but I don’t know what to do about function/path values. I’m also left wondering why checking the non-null attrs isn’t the default behaviour. I’m guessing there’s probably a very valid reason somewhere in the internal workings of nix.

However, I’m be changing my aim towards a list based structure, because I forgot that attrsets don’t keep order. So in my case, there’s no need for this stuff to work just yet. But feel free to continue the thread if you’re stuck at the same problem.

EDIT: I found types.addCheck, which seems quite useful for this usecase!