· 7 min read ·

Nix Gets a Type System, and TypeScript Provides the Blueprint

Source: lobsters

The Nix expression language has no static type system. That single fact describes one of the most persistent frustrations in the Nix and NixOS ecosystem, and one of the sharpest contrasts between the language’s theoretical elegance and its practical day-to-day use. A new project called typenix by Ryan Rasti takes a pragmatic approach to fixing this: rather than designing a new type theory for Nix, it borrows the structural type system from TypeScript.

That borrowing is not as strange as it first sounds. Understanding why requires looking at what Nix actually is, why previous typing attempts fell short, and what TypeScript’s type system was originally designed to solve.

The Problem with Nix’s Types at Scale

Nix has runtime types. Every value in the language has a type: integers, floats, booleans, strings, paths, lists, attribute sets, functions, and derivations. The language evaluates lazily and purely, which means expressions are not evaluated until their values are needed. Type errors surface at that evaluation point, and not before.

For small scripts this is fine. For nixpkgs, the main Nix package collection with over 100,000 package definitions, it is a persistent source of pain. Every package is an attribute set, every NixOS option is an attribute set, and every NixOS module receives and produces attribute sets. Misspell a field name, pass a list where a string is expected, or omit a required argument in a derivation, and you get a runtime error at build time, not at write time. The error message frequently points deep into nixpkgs internals rather than to the user’s actual mistake.

The NixOS module system addresses this with a runtime type checker embedded in Nix itself. Options are declared with explicit types:

options.services.myapp.port = lib.mkOption {
  type = lib.types.port;
  default = 8080;
  description = "Port to listen on";
};

lib.types provides str, int, bool, listOf, attrsOf, submodule, either, nullOr, and others. These are checked when NixOS evaluates the module system, which is a meaningful improvement. The problem is that this is still runtime checking. You learn about mistakes when you run nixos-rebuild switch, not when you finish typing.

Prior Art and Its Limits

Several projects have tried to do better.

Nickel, developed at Tweag, is a configuration language designed as a typed successor to Nix. It uses gradual typing with row polymorphism for records, which allows expressing “a record with at least these fields” in a precise, composable way. The type system is genuinely good. The problem is that Nickel is a separate language. It does not run Nix, it does not integrate with nixpkgs, and adopting it means abandoning the entire existing Nix ecosystem. For most NixOS users, that is not a practical option.

PureNix takes a different approach: it compiles PureScript, a Haskell-like language with a Hindley-Milner type system, down to valid Nix expressions. This gives library authors full static type safety at development time. The output is real Nix, usable in nixpkgs. But you are writing PureScript, not Nix. For anyone maintaining a NixOS configuration or contributing a package, the workflow is completely different from anything they are used to.

The nil language server provides solid IDE support for Nix: variable resolution, basic flow analysis, some inference for obvious cases. It is not a type checker in the structural sense. It will not tell you that the attribute set you are passing to stdenv.mkDerivation is missing a required field.

Why TypeScript’s Type System Fits

TypeScript was built to solve a specific problem: add static types to a dynamically-typed, expression-oriented language where the primary data structures are objects with arbitrary shapes. The solution it converged on was structural subtyping, where two types are compatible if they share the same structure, regardless of their names or declared hierarchy.

Nix is, in type-theoretic terms, a very similar problem. The primary data structures are attribute sets, which are record types with arbitrary fields. Functions are first-class. There is no inheritance, no class hierarchy, no nominal identity. Whether an attribute set is the right type for a given function depends entirely on whether it has the right fields with the right types.

TypeScript’s structural type system was designed for exactly this. An object type { name: string; age: number } is assignable to { name: string } because it has at least all the required fields. Optional properties (?), index signatures for unknown keys ([key: string]: T), union types (A | B), and intersection types (A & B) are all part of the model. Every one of those features has a direct counterpart in how Nix attribute sets are used in practice.

Consider Nix’s function argument patterns:

# Nix function with named arguments and catch-all
{ pkgs, lib, enable ? true, ... }: ...

In TypeScript terms, this function accepts an object type with required fields pkgs and lib, an optional field enable, and an index signature for any additional keys. The structural mapping is nearly one-to-one.

Union types handle Nix’s prevalent string enumeration pattern precisely. NixOS options like the system architecture are frequently strings constrained to a specific set of values:

# A typenix annotation for an architecture option
type System = "x86_64-linux" | "aarch64-linux" | "x86_64-darwin" | "aarch64-darwin";

TypeScript’s string literal union types express this exactly. The NixOS module system has types.enum for this, but it is runtime-checked and requires listing the valid values in a specific Nix API call rather than in a type annotation.

Intersection types map to Nix’s // merge operator. When two attribute sets are merged, the result has all the fields of both. That is an intersection type, and TypeScript’s A & B expresses it directly. The type-level semantics of // have always been intersection semantics; TypeScript just provides the notation.

What typenix Does

typenix implements TypeScript’s type system as a static checker layered on top of Nix. It does not modify the Nix evaluator. Nix remains dynamically typed at runtime. typenix is a separate analysis pass that reads type annotations on Nix expressions and verifies structural compatibility before any evaluation happens.

The type annotation approach allows gradual adoption. Existing Nix code without annotations remains valid. You can annotate a module at a time, a function at a time. This mirrors how TypeScript itself was designed to be adopted incrementally in existing JavaScript codebases, which was a significant reason for its success.

The checker needs to handle Nix’s lazy evaluation model carefully. Nix allows recursive attribute sets where fields reference each other, and NixOS modules use fixed-point evaluation where the output of the module system feeds back into its input via lib.fixedPoints.fix. TypeScript handles recursive types by structurally unfolding them lazily, which mirrors how Nix’s evaluator handles thunks. This is not coincidence so much as a consequence of both systems dealing with the same fundamental challenge: mutually recursive definitions in a lazy setting.

The Practical Payoff

The most immediate benefit is NixOS configuration authoring. When you write a NixOS module, a typenix-aware editor could tell you immediately that services.nignx.enable is not a valid option, that environment.systemPackages expects a list of derivations rather than a list of strings, or that your custom module’s options block declares a field that your config block never populates.

For nixpkgs contributors, the benefit is catching mistakes in derivation attribute sets before submitting a pull request. stdenv.mkDerivation accepts dozens of optional and required fields, and getting one wrong currently means waiting for CI to fail. A typed interface for mkDerivation would surface those mistakes immediately.

The deeper payoff is what TypeScript demonstrated for JavaScript: once types exist, tooling follows. Better completions, reliable go-to-definition, inline documentation for option types, refactoring support that understands what field names mean. The NixOS ecosystem is large enough that these improvements would have real compounding value across the thousands of people who maintain NixOS configurations and nixpkgs packages.

The Trade-offs

TypeScript’s type system was designed for TypeScript’s version of the problem, not Nix’s, and some Nix constructs do not map cleanly. The with expression, which brings an entire attribute set into scope without naming the fields, is a known enemy of static analysis. It is already discouraged in nixpkgs style guides, but it appears frequently in user configurations.

builtins.mapAttrs, lib.mapAttrs, and similar higher-order functions that operate over arbitrary attribute set shapes require polymorphic types. TypeScript supports these via generics, but expressing them correctly for Nix’s specific patterns requires careful modeling.

There is also the question of the existing NixOS module type system. Any serious typenix adoption would need to model lib.types.* as a type-level library, providing TypeScript-style types for all the standard option types. That is significant work, and it would need to stay synchronized with changes to the NixOS module system.

Nickel’s row polymorphism is arguably more theoretically precise for Nix’s record semantics than TypeScript’s object types. Row polymorphism can express exactly “this record has at least these fields” without the width subtyping ambiguities that TypeScript sometimes hits at the edges. But Nickel requires abandoning the ecosystem. PureNix provides full type safety but requires writing a different language. typenix’s bet is that a well-understood, tooling-rich type system applied to the actual Nix language is more useful in practice than a theoretically purer solution that requires starting over.

TypeScript made that same bet for JavaScript in 2012. The result was not a theoretically ideal type system, but it was one that millions of developers adopted because it met them where they already were. For a Nix ecosystem that has grown to the size where untyped configuration is a genuine maintenance burden, that kind of pragmatism has real value.

Was this interesting?