Typeflake

Nix modules in TypeScript, typed by your own configurations.

Typeflake reads the option graph of your NixOS, nix-darwin and Home Manager configurations with nix eval and generates TypeScript types from it. You write modules in Effect TypeScript against those types, and Typeflake renders each one to an ordinary .nix file next to it. That file builds with Nix alone.

Early alpha The probe, the type generator, module rendering and the CLI work. A first port of infra's app-stores module builds the same drvPath as the original on srv-2 and m1.

Source on GitHub Read the design notes

app-stores/default.nix.ts What you write
import { mkIf, nix, NixOS, Options, path } from "typeflake"

export default NixOS.module({
  options: {
    appStores: {
      enable: Options.enable("asc and gplay with the Urbs UG store credentials"),
    },
  },
  config: ({ config, lib, pkgs }) => {
    const secrets = config.sops.secrets
    const asc = pkgs.writeShellApplication({
      name: "asc",
      text: nix`
        ASC_PRIVATE_KEY=$(cat ${secrets["app-store-connect/api-key"].path})
        exec ${lib.getExe(pkgs.callPackage(path("./asc-package.nix"), {}))} "$@"
      `,
    })
    return mkIf(config.appStores.enable, {
      sops: {
        secrets: {
          "app-store-connect/api-key": {
            owner: config.identity.primaryUser,
            mode: "0400",
          },
        },
      },
      environment: { systemPackages: [asc] },
    })
  },
})
app-stores/default.nix What Typeflake renders next to it
{ config, lib, pkgs, ... }:
{
  options.appStores.enable = lib.mkEnableOption "asc and gplay with the Urbs UG store credentials";
  config = lib.mkIf config.appStores.enable {
    sops.secrets."app-store-connect/api-key" = {
      owner = config.identity.primaryUser;
      mode = "0400";
    };
    environment.systemPackages = [
      (pkgs.writeShellApplication {
        name = "asc";
        text = ''
          ASC_PRIVATE_KEY=$(cat ${config.sops.secrets."app-store-connect/api-key".path})
          exec ${lib.getExe (pkgs.callPackage ./asc-package.nix { })} "$@"
        '';
      })
    ];
  };
}

infra's app-stores module, condensed as in the design notes. It typechecks against srv-2's generated types, and the Nix is the renderer's own output. Point at a highlighted part on either side to find its counterpart. Tap a highlighted part on either side to find its counterpart.

One host, 41,038 options

srv-2 is a NixOS server in infra, the repository Typeflake is first built for. Its configuration has 41,038 options once Home Manager's are counted, and 35,962 of them are visible in the manual. They come from nixpkgs, Home Manager, disko, sops-nix and infra's own modules, at the versions flake.lock pins.

Typeflake turns them into 5.9 MB of TypeScript, leaving out only the hidden ones. A typo in an nginx location, a sops-nix mode written as a number or a Home Manager state version that doesn't exist then fails in the editor, before Nix evaluates anything.

Measured on 2026-10-23 against infra, with TypeScript 7.0.2.
srv-2, NixOS m1, nix-darwin
Probe 11 s, 940 MB 3.4 s, 445 MB
Options, Home Manager included 41,038 (35,962 visible) Not measured
Probe output 32 MB of JSON 11 MB of JSON
Generated types 5.9 MB, 6,400 interfaces 1.4 MB
Typecheck of a sample module 0.48 s, 59 MB Not measured
  • nixpkgs 29,027
  • Home Manager 6,061
  • Other flake inputs 5,792 (disko 5,473, nix-infra-modules 267, sops-nix 46, vscode-server 6)
  • infra itself 158

Every block is a namespace in srv-2's option graph.

Point at one to read its path and size. Tap one to read its path and size. With the map focused, the arrow keys step through the namespaces.

Each block is a namespace, sized by its number of options and colored by the flake input that declares most of them. The counts come from the probe output the generated types were built from.
Show the counts as a table
NamespaceOptionsLargest inside
services23,683services.prometheus 2,160, services.xserver 382, services.tor 373
home-manager6,519home-manager.users.<name>.programs 3,862, home-manager.users.<name>.services 1,211, home-manager.users.<name>.accounts 322
disko5,472disko.devices.disk 1,913, disko.devices.mdadm 1,912, disko.devices.zpool 810
programs971programs.zsh 45
boot840boot.initrd 540, boot.loader 149, boot 38
systemd667systemd.user 197, systemd.network 140, systemd.services 49
security479security.pam 156, security.acme 72
networking434networking 49, networking.networkmanager 42, networking.interfaces 39
hardware406hardware.nvidia 27, hardware.cpu 25, hardware.facter 25
vps309vps.services 275
virtualisation277virtualisation.xen 56, virtualisation.libvirtd 42, virtualisation.oci-containers 33
59 smaller namespaces981system 142, users 140, nix 85
All41,03835,962 visible

Ten mistakes that don't compile

These come from the spike that measured the generated types before modules existed: a configuration passed to a function that takes Shape<NixosOptions>. It sets sshd's freeform settings, nginx virtual hosts, sops-nix secrets, Home Manager and options infra declares itself. Modules check against the same types.

Pick a mistake to make it in the file and read what tsc reports.

sample.ts Checked with TypeScript 7.0.2
import type { NixosOptions } from "./options.ts"
import type { Nix, Package, Shape } from "./prelude.ts"

declare const pkgs: { readonly git: Package; readonly ripgrep: Package }
declare const hostName: Nix<string>
declare const enabled: Nix<boolean>
declare const port: Nix<number>
const nixos = (config: Shape<NixosOptions>) => config

nixos({
  appStores: { enable: true },
  identity: { primaryUser: "hauke" },
  networking: {
    hostName,
    firewall: { allowedTCPPorts: [22, port, 443] },
  },
  environment: { systemPackages: [pkgs.git, pkgs.ripgrep] },
  services: {
    openssh: {
      enable: enabled,
      ports: [22],
      settings: {
        PermitRootLogin: "no",
        PasswordAuthentication: false,
        ClientAliveInterval: 60,
      },
    },
    nginx: {
      enable: true,
      virtualHosts: {
        "files.schnau.dev": {
          forceSSL: true,
          locations: { "/": { proxyPass: "http://127.0.0.1:8080" } },
        },
      },
    },
  },
  sops: {
    secrets: {
      "app-store-connect/api-key": { owner: "hauke", mode: "0400" },
    },
  },
  users: { users: { hauke: { isNormalUser: true, extraGroups: ["wheel"] } } },
  "home-manager": {
    users: {
      hauke: {
        home: { stateVersion: "25.11" },
        programs: { git: { enable: true } },
      },
    },
  },
})
$ tsc --noEmit
No errors.

Each error above is what tsc printed for that exact file, checked against the 5.9 MB of types generated from srv-2. Identical interfaces are shared and named after the first option path that has them, which is why nginx's locations carry agorakit's name.

From option graph to plain Nix

  1. Probe the option graph

    Works today

    src/probe.nix walks a configuration's options with builtins only and prints the tree as JSON. It reads the module system's type metadata, such as nested types, enum values and submodule options, and never parses descriptions. srv-2 takes 11 seconds.

    Shell
    nix eval --impure --json .#nixosConfigurations.srv-2.options \
      --apply 'import ./src/probe.nix { class = "nixos"; }'
    srv-2.json One option, as the probe prints it
    {
      "PermitRootLogin": {
        "id": "/nix/store/fjr02205wq70r3vghhfbfzv5g97nr4ya-source/nixos/modules/services/networking/ssh/sshd.nix:554:15#PermitRootLogin",
        "description": "Whether the root user can login using ssh.\n",
        "type": {
          "description": "null or one of \"yes\", \"without-password\", \"prohibit-password\", \"forced-commands-only\", \"no\"",
          "name": "nullOr",
          "nested": {
            "elemType": {
              "description": "one of \"yes\", \"without-password\", \"prohibit-password\", \"forced-commands-only\", \"no\"",
              "name": "enum",
              "values": ["yes", "without-password", "prohibit-password", "forced-commands-only", "no"]
            }
          }
        },
        "visible": true
      }
    }
  2. Generate types

    Works today

    src/Typegen.ts turns the tree into interfaces that describe evaluated values. Groups and submodules become interfaces named after their first option path, and identical ones are shared. An option's description becomes its JSDoc, so the editor shows it on hover. typeflake types nixosConfigurations.srv-2 runs the probe and the generator and writes the result into .typeflake/.

    options.ts An excerpt of what the generator wrote
    export type ServicesOpensshSettingsPermitRootLoginValue = "yes" | "without-password" | "prohibit-password" | "forced-commands-only" | "no" | null
    
    export interface ServicesOpensshSettings {
      /**
       * Specifies whether password authentication is allowed.
       */
      readonly PasswordAuthentication: boolean | null
      /**
       * Whether the root user can login using ssh.
       */
      readonly PermitRootLogin: ServicesOpensshSettingsPermitRootLoginValue
      /**
       * Whether to enable printing /etc/motd when a user logs in interactively.
       */
      readonly PrintMotd: boolean | null
    }
  3. Write a module

    Works today

    A module is a *.nix.ts file like the one at the top. Generated declarations augment a Register interface, so NixOS.module(…) is typed without annotations. The first port typechecks against srv-2's types in 0.77 seconds, and four mistakes injected into it all fail.

  4. Render it to Nix

    Works today

    typeflake render writes each module's .nix file next to it, and --check fails when one is out of date. Your flake.nix imports the result like any other module. Delete the .ts files and the repository still builds.

  5. Check the port

    Done for app-stores

    A port must not change what any host builds. For app-stores, each host swapped the module once for a copy of the original and once for the rendered port, and both printed the same drvPath, on srv-2 and on m1.

    Shell
    nix eval --raw .#nixosConfigurations.srv-2.config.system.build.toplevel.drvPath

Rules it keeps

Where it stands

Works today

  • The option probe on NixOS and nix-darwin hosts, with Home Manager's options from the same NixOS probe.
  • The type generator, and typeflake types to run both for a host.
  • Modules: NixOS.module, Options, nix`…`, mkIf and the proxies for config, lib and pkgs.
  • typeflake render, with --check for drift.
  • A first port of infra's app-stores module, with an unchanged drvPath.

Next

  1. Probe again when flake.lock changes, and generate package names with the probe.
  2. Port a module with submodule options, and one shared between NixOS and nix-darwin, through the CLI into infra.
  3. Later, flake.ts, typed flake inputs and nested package sets.
ShellEarly alpha
pnpm add -D typeflake
pnpm exec typeflake types nixosConfigurations.srv-2
pnpm exec typeflake render

Early alpha releases of this rebuild go to npm from 0.1.0 on, and typeflake is the CLI they install. The 0.0.1 versions there are from an earlier prototype.