Getting Started
What tynix Is
tynix is a static type layer for Nix.
.tynixkeeps ordinary Nix value syntax- types are used for checking, hover, and declaration emit
- generated
.nixerases all type syntax
If you already know Nix, the goal is that tynix feels like "Nix plus a type surface", not a different runtime language.
Installation
Install script (Linux, macOS)
curl -fsSL https://tynix.dev/install.sh | shThe script detects your OS and CPU, downloads the matching release archive,
verifies its SHA-256 checksum, and installs tynix and tynix-lsp into
~/.tynix/bin (it prints the line to add to your shell profile if that
directory is not on PATH). The binaries do not need Nix: Linux builds are
fully static, and macOS builds only link system libraries.
Options can be passed after sh -s --, or as environment variables:
# A specific release
curl -fsSL https://tynix.dev/install.sh | sh -s -- --version 0.5.0 # or TYNIX_VERSION=0.5.0
# A custom install directory
curl -fsSL https://tynix.dev/install.sh | TYNIX_INSTALL_DIR="$HOME/.local/bin" sh
# Uninstall
curl -fsSL https://tynix.dev/install.sh | sh -s -- --uninstallRe-running the script upgrades to the latest release.
Nix flake
# Install tynix and tynix-lsp into your profile
nix profile install github:ubugeeei-prod/tynix
# Or run without installing
nix run github:ubugeeei-prod/tynix -- check ./main.tynixThe flake also exports overlays.default (adds pkgs.tynix, pkgs.tynix-lsp
and pkgs.tynix-toolchain) and modules that install the toolchain with
programs.tynix.enable = true;:
# flake.nix: inputs.tynix.url = "github:ubugeeei-prod/tynix";
# NixOS (use tynix.darwinModules.default for nix-darwin)
{ inputs, ... }:
{
imports = [ inputs.tynix.nixosModules.default ];
programs.tynix.enable = true;
}
# Home Manager
{ inputs, ... }:
{
imports = [ inputs.tynix.homeManagerModules.default ];
programs.tynix.enable = true;
}Supported platforms
Prebuilt tynix and tynix-lsp archives ship for Linux x64, Linux arm64, macOS
arm64 (Apple silicon), and macOS x64 (Intel). Other platforms can build from
source through the Nix flake. Windows is not tested today. Use WSL2 and the
Linux install script there. See support-matrix.md for
the full per-platform tier table.
Check the install:
tynix --version
tynix-lsp --versionFirst Commands
From the repository root:
nix developTypical CLI entry points:
tynix init .
tynix scaffold .
tynix check ./examples/main.tynix
tynix compile ./examples/main.tynix -o ./dist/main.nix
tynix emit ./examples/main.tynix -o ./dist/main.d.tynixIf you are using the published flake directly:
nix run github:ubugeeei-prod/tynix#tynix -- check ./main.tynix
nix run github:ubugeeei-prod/tynix#tynix -- compile ./main.tynix -o ./main.nix
nix run github:ubugeeei-prod/tynix#tynix -- emit ./main.tynix -o ./main.d.tynixTo set up an editor, run tynix ide install vscode (or cursor, vscodium,
zed, neovim, helix), then tynix doctor. See Editor Setup.
Scaffolding A Project
tynix init creates a starter project in the target directory:
tynix.config.tynixtynix.config.d.tynixsrc/main.tynixtypes/builtins.d.tynix
builtins and the global builtins (toString, map, throw, ...) are typed
out of the box by a prelude embedded in the binary. A workspace
declare "builtins" block replaces that prelude, so delete the scaffolded
types/builtins.d.tynix unless you want to restrict the builtins, and set
builtins = false; to keep tynix scaffold from recreating it.
The generated config is ordinary tynix syntax:
{
name = "demo";
sourceDir = ./src;
entry = ./src/main.tynix;
declarationDir = ./types;
declarationPacks = [];
buildDir = ./dist;
generatedDeclarationDir = ./dist/types;
entries = [];
include = [];
exclude = [];
builtins = true;
}You can later re-run:
tynix scaffold .to materialize any missing scaffold files without overwriting existing ones.
The generated tynix.config.d.tynix lets other typed files import the project
config with a stable declaration instead of treating it as untyped.
Your First .tynix File
let
greeting :: String;
greeting = "hello";
in greetingChecking this file validates the annotation and infers the root type.
Compiling it produces ordinary Nix:
let
greeting = "hello";
in greetingRecords And Field Access
tynix uses structural typing for attribute sets.
let
pkg :: { name :: String; version :: String; };
pkg = { name = "tynix"; version = "0.1.0"; };
in pkg.nameThe checker understands the field projection and infers the root type as String.
Ambient Typing For Existing .nix
You can type legacy .nix modules without rewriting them.
declare "./legacy/default.nix" {
default :: { name :: String; version :: String; };
};
import ./legacy/default.nixThis is the main bridge for incremental adoption:
- keep the runtime implementation in
.nix - describe its public API in
.d.tynixor inlinedeclare - use that API from typed
.tynix
Nix Syntax
.tynix accepts the whole Nix expression language, so existing code can be
renamed to .tynix and annotated gradually. For example:
{ lib ? null, name ? "demo", version, ... }@args:
let
base = { meta.license = "MIT"; meta.homepage = "https://example.org"; };
inherit (base.meta) license;
greeting = "${name}-${version}";
in base // {
inherit greeting license;
half = 7 / 2;
safe = args.extra.port or 8080;
piped = [ 1 2 3 ] |> builtins.length;
implied = true -> false;
src = ./${name}.nix;
}Attribute-set patterns with defaults and @ binders, inherit (src), nested
(a.b.c = 1;) and dynamic (${k} = v;) attribute paths, x.a or default,
x ? ${k}, every operator (including /, ->, |> and <|), <nixpkgs>,
~/ and interpolated paths, and $$ string escapes all parse and type-check.
Only Nix keywords are reserved: type, any, import and declare are
ordinary names in expressions, and as is an ordinary name except in the
expr as Type cast. See the language reference.
Flake Workflow
A flake can be written directly as flake.tynix and compiled to flake.nix;
the flake tutorial walks through
it. Annotate the inputs you use, { self, nixpkgs :: NixpkgsInput, ... }:, and
every lookup through them is checked.
If you would rather keep flake.nix hand-written, describe it with a
declaration and check a typed projection of the parts you care about:
declare "./flake.nix" {
description :: String;
outputs :: dynamic -> {
packages :: { x86_64-linux :: { default :: Derivation; }; };
};
};
let
flake = import ./flake.nix;
outputs = flake.outputs { };
in {
description = flake.description;
package = outputs.packages.x86_64-linux.default;
}Derivation is one of the aliases the built-in prelude provides.
Bundled Ecosystem Declarations
The repository ships curated declaration packs under registry/ for both local
workspace files and common Nix ecosystem surfaces. They are meant to be copied
or vendored into your project when you want a DefinitelyTyped-style starting
point instead of handwriting every ambient declaration.
Directory layout:
registry/workspace/forbuiltins,flake.nix, andtynix.config.tynixregistry/ecosystem/for reusable alias packs such asnixpkgsand popular flakes
Available packs currently cover:
nixpkgs.libpkgs/import nixpkgsflake-utils,home-manager,nix-darwin,flake-partsdevenv,treefmt-nix,pre-commit-hooks.nix,crane,deploy-rs,nixvim,sops-nix,agenix,disko,colmena
Example:
declare "./flake-utils.nix" { default :: NixFlakeUtilsFlake; };
declare "./devenv.nix" { default :: DevenvFlake; };
declare "./pre-commit-hooks.nix" { default :: PreCommitHooksFlake; };This keeps the runtime import path local to your project while reusing stable alias names from the bundled registry packs.
If you want to consume the upstream packs without copying them into your
repository, list them in declarationPacks:
{
declarationPacks = [
../vendor/tynix/registry/ecosystem
../vendor/tynix/registry/workspace
];
}registry/workspace/ packs are rebased onto your current project root, so
their ambient declarations still target your local flake.nix and
tynix.config.tynix.
Lists, Vectors, And Matrices
Plain Nix list syntax can infer more precise indexed shapes.
{
pair = [1 2];
grid = [[1 2] [3 4]];
ragged = [[1] [2 3]];
}root: {
grid :: Matrix 2 2 (1 | 2 | 3 | 4);
pair :: Vec 2 (1 | 2);
ragged :: List (Vec (1 | 2) (1 | 2 | 3));
}You can also write shape annotations directly:
let
xs :: Vec 3 Int;
xs = [1 2 3];
in xsBounded lengths are supported too:
let
xs :: Vec (Range 2 4 Nat) Int;
xs = [1 2 3];
in xsNumeric Validation
tynix includes a small refinement surface for numeric values.
let
ratio :: Range 0.0 1.0 Float;
ratio = 0.5;
in ratioThis passes, while:
let
ratio :: Range 0.0 1.0 Float;
ratio = 1.5;
in ratiois rejected.
Units
Units are phantom wrappers that survive checking but disappear at runtime.
let
timeout :: Unit "ms" (Range 0 5000 Nat);
timeout = 2500;
in timeoutDifferent labels stay distinct:
let
timeoutMs :: Unit "ms" Nat;
timeoutMs = 1;
timeoutS :: Unit "s" Nat;
timeoutS = timeoutMs;
in timeoutSThe assignment to timeoutS is rejected.
Casts
tynix also supports explicit as casts for the places where you want to
assert a more useful static view.
let
value :: unknown;
value = 1;
in value as IntThis is accepted because the cast is explicit. Widening casts work too:
1 as NumberConcrete unrelated casts are still rejected:
1 as StringAt compile time the cast disappears, so the generated .nix still contains
only the original runtime expression.
Diagnostic Directives
tynix supports TypeScript-style line comments for intentional checker failures.
Ignore the next line's checker error:
let
# @tynix-ignore
value = missing;
in valueExpect the next line to fail and report an error if it does not:
let
# @tynix-expected
value :: Int;
value = "oops";
in valueThese directives apply to the root expression or to one let item.
Declaration Emit
tynix emit turns a .tynix file into a .d.tynix API surface.
Source (user.tynix):
type User = { name :: String; };
{
make = (name :: String): { inherit name; } as User;
}Emitted declaration:
type User = {
name :: String;
};
declare "./user.nix" {
make :: String %1 -> User;
};The declare target is not a literal string: tynix emit derives it from the
source path by replacing the .tynix extension with .nix, expressed relative
to the emitted .d.tynix file's directory. So emitting widget.tynix produces
declare "./widget.nix" { … }. The %1 -> arrow marks a function that uses its
argument exactly once; see Annotations and inference.
Suggested Learning Path
- Start by renaming a file to
.tynixand runningtynix check; inference and the builtins prelude cover a lot without any annotations. - Add annotations on
letbindings and function parameters where you want to state intent. - Add ambient declarations for existing
.niximports. - Use
emitto stabilize public APIs between files. - Add
tynix.config.tynixandtynix scaffoldonce the project layout is settling. - Reach for
Vec/Matrix/Tensor,Range, andUnitwhen the shape or numeric contract actually matters.