No description
  • Nix 98.5%
  • Python 1.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
natsukium af98c029c5
docs: state what the sidecar does not validate and where the types bend
The sidecar hands home-manager the JSON through toJSON, so nothing
runs the validator over it unless the consumer builds validate as a
check; the README said the file could not be invalid. The one place
the types are stricter than the schema, an inferred object or array,
and the depth at which a recursive property documents were missing
from the same guarantees. The order of work is gone from the design,
since every part of it is in the tree.

Assisted-by: Claude Code:Fable 5.1
2026-09-13 19:47:16 +09:00
docs docs: state what the sidecar does not validate and where the types bend 2026-09-13 19:47:16 +09:00
example docs: add the examples the design names 2026-09-13 19:36:54 +09:00
lib feat: hint a schema from outside with pointer-keyed overlays and injected types 2026-09-13 19:35:14 +09:00
tests feat: hint a schema from outside with pointer-keyed overlays and injected types 2026-09-13 19:35:14 +09:00
flake.lock docs: add the examples the design names 2026-09-13 19:36:54 +09:00
flake.nix docs: add the examples the design names 2026-09-13 19:36:54 +09:00
README.md docs: state what the sidecar does not validate and where the types bend 2026-09-13 19:47:16 +09:00

jsonschema2nix

Turn a JSON Schema into NixOS module options. A settings-style option typed from a schema gets completion, documentation, merging across modules and early type errors, and the file written from it is validated against the original schema, so an invalid configuration cannot build.

Nothing is generated into your repository: the schema is read at evaluation time. The one exception is a lock file for schemas addressed by URL.

Usage

A schema in the repository, with or without relative $refs to sibling files:

{ lib, config, options, pkgs, ... }:
let
  schema = jsonschema2nix.lib.fromFile ./schema.json;
in
{
  options.services.foo.settings = lib.mkOption { type = schema.type; };
  config.services.foo.configFile = schema.generate pkgs {
    name = "foo.json";
    option = options.services.foo.settings;
  };
}

generate takes the option, not its value: only what the configuration sets is written. A schema default becomes the option's default, so it is what config reads and what the documentation shows, but it is not written to the file, because it is the tool's own default and writing it would freeze it at the schema's revision. Pass check = false to skip validation.

A schema addressed by URL. The lock records the URL, every document it references, their hashes, and where to fetch them from; re-run the same command to update it. Schema URLs are not versioned, so where a host also serves the same bytes at a revision-pinned URL the lock points at that.

nix run github:natsukium/jsonschema2nix#lock -- https://json.schemastore.org/pyproject.json > pyproject.lock.json
schema = jsonschema2nix.lib.fromLock ./pyproject.lock.json;

The record returned by fromFile and fromLock follows the shape of pkgs.formats:

type the root type
toJSON <option> the JSON value the option's definitions wrote
generate pkgs { name; option; check ? true; } the validated file
validate pkgs { name; value; } validate a value headed for another format
override { overlays; types; } hints keyed by JSON pointer, see below
report what the types leave to the validator, by pointer

lib needs only nixpkgs' lib; pkgs is taken where a derivation is built. nixpkgs 25.11 or later is required.

Existing options

home-manager's programs.starship.settings and the like are declared with a bare format type, which nothing can extend. The typed option sits beside it and feeds it:

options.programs.starship.typedSettings = lib.mkOption { type = schema.type; };
config.programs.starship.settings = schema.toJSON options.programs.starship.typedSettings;

home-manager then writes the file as it always does, presets and all. What reaches the file is typed but not validated, because home-manager writes it, not generate; to keep the guarantee, build validate over the same value as a check:

checks.starship-settings = schema.validate pkgs {
  name = "starship.json";
  value = config.programs.starship.settings;
};

What the types check, and what they leave to the validator

The types carry what the module system can express: object shapes, required keys, enums, unions, numeric and length bounds, ECMAScript patterns that POSIX ERE can hold. Everything else (format, if/then/else, not, patterns with lookaround, a fractional multipleOf, ...) is checked by the validator when the file is built. schema.report lists, by JSON pointer, what a given schema leaves to that step. The types never reject a value the schema accepts, with one deliberate exception: a subschema that writes properties or items without a type is read as an object or an array, which the report names as an inferred type.

Where a schema cannot be typed as written, override merges subschema fragments into it before typing, or injects a lib.types value at a pointer. The validator still checks against the untouched upstream schema, so a wrong hint is caught rather than hidden.

Editor completion

nixd descends into a submodule option by calling getSubOptions on its type, so pointing it at the evaluated options of a configuration gives completion, hover and defaults for every schema property:

{
  "nixd": {
    "options": {
      "home-manager": {
        "expr": "(builtins.getFlake \"/path/to/config\").homeConfigurations.\"user\".options"
      }
    }
  }
}

It does not descend into a union or a nullable object; that is a nixd limitation shared by every module. A property whose type refers back to its own definition documents one level deep, so completion stops there too.

Design

docs/design.md: the four stages, the rules each applies, what the schemas it was run against exposed, and unsupported features grouped by how much work they are.