- Reproduce — write a failing test first (in
internal/nixparser/lsptest/orinternal/nixparser/nixtest/) - Fix — make the test pass
- Verify — run the broader test suites to check for regressions
- Zero type errors on all 42,298
.nixfiles in nixpkgs in 13 seconds - Full type inference flowing through imports,
callLibs,makeExtensible - Hover, go-to-definition, and completions in the LSP across
.nixfiles - Class transform for fixed-point patterns (
makeExtensible,fix, etc.) — maps nix's self-referential attrsets to TypeScript classes so the checker understands them - Minimal annotations — most of nixpkgs is unannotated and just works
- Found real nixpkgs bugs during development:
ledfx(pyproject.pkgs→python3.pkgs),lib/cli.nix(lib.throw→builtins.throw), dead code afterthrowin 3 files
Path expressions like ./foo.nix carry a phantom type brand: string & { readonly __import: typeof import("./foo.nix")["default"] }. When passed through builtins.import, a conditional type extracts the module's exported type. This threads module types through nix's import system without runtime cost — the brand is erased, but the checker sees full type information flow through variables, callLibs, and callPackage.
Instead of sanitizing nix identifiers that clash with JS reserved words (throw → _throw), we suppress the relevant binder/checker errors (TS1214, TS1215, TS2730) for .nix files. JS reserved words are valid as property names, and since we build the AST programmatically, the parser never rejects them. Bare throw/import references that aren't in scope are rewritten to builtins.throw/builtins.import.
makeExtensible (self: { foo = 1; bar = self.foo + 1; }) becomes a TypeScript class where self → this. This lets TypeScript's this-typing handle the self-referential nature of nix's fixed-point combinators. Let bindings in the lambda become #private class fields to avoid name conflicts.