Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Type Annotations

TLDR: Annotate bindings with doc comments when inference isn’t enough. Three flavors: nixdoc-style # Type sections, inline /** type: name :: Type */ block comments, and # type: name :: Type line comments.

When you need annotations

Most code doesn’t need annotations — tix infers types from usage. Annotations help when:

  • You’re importing code via a path tix can’t resolve (e.g. import <nixpkgs>, dynamic paths)
  • You want to constrain a binding to a specific type
  • You want to document your API

Doc comment format

Nixdoc-style (multiline)

Follows the nixdoc convention. The type goes in a fenced code block under a # Type heading:

/**
  Concatenate a list of strings with a separator.

  # Type

  ```
  concatStringsSep :: string -> [string] -> string
  ```
*/
concatStringsSep = sep: list: builtins.concatStringsSep sep list;

Inline type annotation

For quick one-liners, use either block comments or line comments:

/** type: lib :: Lib */
lib = import <nixpkgs/lib>;

/** type: add :: int -> int -> int */
add = a: b: a + b;

Line comments work too — handy for attrset pattern parameters where /** feels heavy:

{
  # type: pkgs :: Pkgs
  pkgs,
  # type: lib :: Lib
  lib,
}: ...

The type: prefix distinguishes annotations from regular comments.

Assigning type aliases

When you import something typed by stubs, you assign it a type alias:

/** type: lib :: Lib */
lib = import <nixpkgs/lib>;

/** type: pkgs :: Pkgs */
pkgs = import <nixpkgs> {};

Lib and Pkgs are type aliases defined in the built-in stubs (or your custom .tix files). This tells tix “trust me, this import produces a value of this type.”

Type expression syntax

The same syntax works in doc comments and .tix stub files.

Casing matters: lowercase names like a and b are generic type variables (implicitly universally quantified), while uppercase names like Foo or Lib are references to type aliases. This is how the parser tells them apart — val id :: a -> a means “for any type a”, whereas val f :: Lib -> Lib means “takes and returns the specific Lib type”. This is also why module lib { ... } generates a capitalized alias Lib: the module’s name is lowercase (matching Nix convention), but its type alias must be uppercase to be usable in type expressions.

SyntaxMeaning
int, string, bool, float, path, nullPrimitives
Int, String, Bool, Float, Path, NullUppercase aliases (same as lowercase)
a, b (lowercase)Generic type variables
Foo (uppercase)Type alias reference
[a]List of a
a -> bFunction (right-associative)
a | bUnion
a & bIntersection
{ name: string, age: int }Closed attrset
{ name: string, ... }Open attrset
{ _: int }Dynamic field type (all values are int)

| typeof varname | Inferred type of a binding | | typeof import("./path.nix") | Inferred root type of another file | | import("./path.nix").Name | Type declaration from another file | | Param(T) | Parameter type of a function type | | Return(T) | Return type of a function type | | T.key | Field type from an attrset type |

Precedence (low to high): -> then | then & then atoms. Use parens to override.

(int | string) -> bool        # function from union to bool
(int -> int) & (string -> string)  # intersection of two function types
Param(typeof f)               # parameter type of f

See Cross-File Types for details on typeof, type operators, and cross-file type imports.

Uppercase primitives

Nixpkgs doc comments conventionally use uppercase names like String, Bool, and Int. Tix recognizes these as aliases for the lowercase primitives, so both string and String work in annotations.

Inline Type Aliases

You can define type aliases directly in a .nix file using doc comments, without needing a separate .tix stub file:

/** type Derivation = { name: string, src: path, ... }; */
# type Nullable = a | null;

let
  /** type: mkDrv :: { name: string, ... } -> Derivation */
  mkDrv = { name, src, ... }: { inherit name; system = "x86_64-linux"; };
in ...

Both block (/** ... */) and line (# type Foo = ...;) comments work. The syntax is exactly the same as in .tix stub files — type Name = TypeExpr;.

Inline aliases are file-scoped (visible everywhere in the file regardless of placement) and shadow any aliases with the same name from loaded stubs.

Disambiguation: type: (with a colon) triggers a binding annotation (type: name :: Type). type (with a space followed by an uppercase letter) triggers an alias declaration (type Name = ...;).

Annotation safety

Tix checks that annotations are compatible with the inferred types. In a few cases, annotations are accepted without full verification — a warning is emitted so you know the annotation is trusted rather than checked:

Arity mismatch. If the annotation has fewer arrows than the function’s visible lambda parameters (e.g. foo :: string -> string on a two-argument function x: y: ...), the annotation is skipped. An annotation with more arrows than visible lambdas is fine — the body may return a function.

Union types. Annotations containing union types (e.g. f :: string -> (string | [string]) -> string) are currently trusted without verification. The function is still type-checked based on its body alone.

Intersection types (overloaded functions). Annotations with intersection function types (e.g. (int -> int) & (string -> string)) are accepted as declared types for callers but the individual overloads aren’t verified against the body. This is useful for declaring overloaded APIs where the implementation dispatches with type guards (builtins.isInt, etc.).

Named alias display in functions

When a function annotation references type aliases for its parameters, tix propagates the alias names through the function’s lambda structure. This means the alias name is displayed instead of the expanded structural type:

# In a .tix stub:
# type BwrapArg = { escaped: string, ... } | string;

/** type: renderArg :: BwrapArg -> string */
renderArg = arg: ...;

Hovering over renderArg displays BwrapArg -> string rather than the fully expanded union/intersection type. This works for curried functions too — each parameter position preserves its alias name independently.