Native Instruction Static Host AKA Turbo Typescript. A fast subset of typescript without the encumbrance of javascript.
An ahead-of-time compiler for a strictly static subset of TypeScript — LLVM IR in the middle, native binaries at the end. No interpreter, no garbage collector, nothing to ship beside the executable.
Nish parses source with its own lexer and parser, rejects everything dynamic
(any, prototypes, eval, exceptions, a garbage collector), and emits
textual LLVM IR (.ll). LLVM's own toolchain (clang / llc) then optimises
and produces native binaries for x86_64, ARM64, or WebAssembly. The compiler
is itself written in Nish, in src/, and ships as a native binary with no
runtime dependency.
TypeScript source ──▶ AST ──▶ validator + checker ──▶ LLVM IR (.ll) ──▶ clang/llc ──▶ native binary
(src/parser.ts) (src/checker.ts) (src/emit*.ts) + runtime/runtime.c + runtime-os.c
If it compiles, every value has one fixed, known memory layout; binaries are a few kilobytes; there is no interpreter and no GC anywhere in the pipeline.
Caution
Do not use Nish in production. It is a very early, pre-alpha experiment in how far a static subset of TypeScript can go when it is compiled straight to native code, not a tool to build on yet. Expect miscompilations, crashes, missing features and diagnostics that are wrong or confusing. The compiler, the runtime and the standard library, cryptography included, have had one audit by the project itself (docs/security/), which fixed what it found and left findings open. No one independent has reviewed them. Nothing it produces should handle data, money or systems you care about. To report a vulnerability, see SECURITY.md.
Warning
Everything still changes. Until 1.0, the language, the CLI flags and the IR the compiler emits all change without notice, often from one minor release to the next. Every commit compiles, tests and bootstraps itself (see Project status for what is done and what is next), but nothing here is frozen. If you try it anyway, pin an exact version rather than a range, expect to fix your source when you move to a newer one, and read CHANGELOG.md before you upgrade.
Requirements: Node.js 22.18+ and, to produce binaries, clang (LLVM 18) + lld; per-OS install commands are in docs/INSTALL.md.
npm install -g @amritk/nish # or per project: npm i -D @amritk/nish
npx @amritk/nish --version # or run it without installing
# or, with no Node at all:
curl -fsSL https://raw.githubusercontent.com/amritk/nish/main/install.sh | sh
# `nish` on the registry is an unrelated package from 2014, so this one is
# scoped; the command it installs is still `nish`. npm fetches the prebuilt
# native compiler for your machine alongside it.Note
From a release tarball rather than the registry, install the pair. The main package is a launcher and carries no compiler: installed on its own it has nothing to hand over to and says so, exiting 3. The same is true on a platform this project publishes no binary for — musl, FreeBSD, 32-bit anything — which since 0.6.0 gets a diagnostic naming the four that do rather than the TypeScript compiler under Node that used to ship beside it. docs/INSTALL.md §2 has the table of what is built for what, and what is left for a platform outside it.
Tip
Building from source works just as well: git clone, npm ci,
bash scripts/fetch-seed.sh, npm run build, then build/nish ... wherever this README says nish.
The build is a bootstrap: the previous release's nish (the seed, fetched
by scripts/fetch-seed.sh, or named with NISH_BOOTSTRAP=<path>) compiles
src/, and that compiler compiles src/ again into build/nish.
hello.ts:
export const main = (): number => {
console.log("hello from Nish");
return 0; // the process exit code
};nish hello.ts --link hello # writes hello.ll, then builds hello with clang -O3 -flto
./hello # hello from Nish
nish hello.ts -o hello.ll # IR only
nish run hello.ts # build into a cache and run it: a script, still nativeThe IR is readable as is. examples/add.ts compiles to:
declare extern_weak void @nish_panic_overflow(i32 noundef) #1
declare { i32, i1 } @llvm.sadd.with.overflow.i32(i32, i32) #2
define noundef i32 @add(i32 noundef %a, i32 noundef %b) #0 {
entry:
%0 = call { i32, i1 } @llvm.sadd.with.overflow.i32(i32 %a, i32 %b)
%1 = extractvalue { i32, i1 } %0, 0
%2 = extractvalue { i32, i1 } %0, 1
br i1 %2, label %ovf.fail, label %ovf.ok
ovf.ok:
ret i32 %1
ovf.fail:
call void @nish_panic_overflow(i32 0)
unreachable
}
attributes #0 = { nounwind }
attributes #1 = { nounwind noreturn cold }
attributes #2 = { nounwind willreturn readnone }Nothing bounds a and b, so the + is checked: an overflow panics with
attempt to add with overflow rather than wrapping or being undefined, and a
sum the compiler proves fits is a plain add nsw instead
(Semantics decisions).
--plain drops the attributes and alignment hints and leaves
define i32 @add(i32 %a, i32 %b) with the same body. Every construct's IR
is in docs/IR_COOKBOOK.md.
New to Nish? The user guide teaches the language page
by page, from installing it to threads, with every example compiled by
npm test.
The full reference is docs/LANGUAGE.md; every rule
there cites the test case that proves it. If an AI is writing the program —
or you want the whole language in one pass rather than as a reference to browse
— read docs/AI.md instead: the same rules, ordered by which
TypeScript reflex they reject, with every example compiled by npm test. Both
ship in the npm package, and llms.txt indexes them.
| Feature | Summary | Reference |
|---|---|---|
| Types | number (i32 by default, double with --number-mode f64), i32, i64, f64, boolean, string, T[], classes, interfaces, T | null, void; 1:1 LLVM mapping, no implicit conversions |
Types |
| Functions and modules | annotated signatures, calls in any order, export/named relative import, whole-program attribute facts, export const main as the entry, internal linkage for everything not exported |
Declarations |
| Control flow | if/else, while, do, for, for...of, break/continue, boolean-only conditions, definite return, unreachable-code errors |
Statements |
| Expressions | + - * / % (integer / and % checked: zero divisor or MIN / -1 panics), numeric-only ordering, === !== (strings by content), && ||, ?:, op=, ++/--, template literals, contextual numeric literals |
Expressions |
| Strings | immutable UTF-8 (.length is the byte length), literals as constant data, +, ===, templates, console.log |
Builtins, Semantics |
| Arrays | T[] with one element type, literals, new Array<T>(n) zero-filled, bounds-checked a[i] (panic, or uncheckedGet from nish:unsafe), .length, push, for...of |
Arrays |
| Classes and interfaces | LLVM structs with clang's layout, constructors, methods, readonly, definite assignment, object literals, implements by identical layout |
Classes |
| Generics | generic functions, classes, interfaces and methods by monomorphisation: one specialised define per instantiation, type arguments inferred at a call and written after new or in an annotation, <T extends Shape> constraints, exported instantiations callable from C and JavaScript as nish_gen_identity_i32 |
Generic functions |
| Memory | no GC: objects that provably do not escape their function are stack allocas, functions whose temporaries die with them get an automatic arena scope, using a = arena() frees a batch the compiler has checked nothing outlives, and Arena.mark/Arena.used read the arena (Arena.reset and Arena.release are deprecated) |
using a = arena(), Arena, Memory |
T | null |
for class, interface, array and string types; === null, and narrowing to T by if, early return, while, &&, ?:, enforced by the checker |
Nullable types |
| Errors | Rust-style Result<T, E> with Ok/Err, isOk()/isErr(), orReturn() (the ?), unwrapOr, expect; the checker refuses to let a failure be dropped or the success payload be read before the error is handled. No throw, no unwinding |
Result and error handling |
| Builtins | console.log, Math.* as LLVM intrinsics (ECMAScript pow corner cases included), Math.random, toI32/toI64/toF64, process.exit, readFileSync/writeFileSync/appendFileSync; the runtime-backed ones are also importable from nish:fs / nish:process / nish:io / nish:net, which is the same builtin under a name nothing can shadow; nish:unsafe (unchecked access, defined wrapping) and nish:secret (Secret<T>, key material the checker keeps in and wipes) are reached only by import |
Builtins, Builtin modules |
| Rejected | any, unknown, var, ==, ?., ??, generic type aliases, async, try, throw, typeof, delete, prototypes, Object.assign, string-keyed access, ... with exact messages |
Forbidden constructs |
| Semantics that differ from JavaScript on purpose: signed integer overflow | ||
panics unless the compiler proves the operation fits (wrappingAdd, |
||
wrappingSub and wrappingMul from nish:unsafe wrap two's-complement; the |
||
| unsigned widths wrap either way), integer division by zero panics instead of | ||
yielding 0, .length counts bytes, there is no throw and no unwinding, |
||
toI32 saturates, Math.min/max take two arguments. The reasons are in the |
||
| FAQ; docs/wp13-differential.md | ||
| lists everything the differential test suite found that still differs from | ||
| Node. |
std/ is Nish written in Nish, for Nish programs to import:
std/testing, a test runner, so a compiled program can check
itself and answer an exit code with no Node in the picture,
std/text, the string operations a program would otherwise write
inline — the language has no split, trim or regular expression, because each
of those allocates and some need a character table the runtime has no room for —
and std/json, the value of one field of one flat JSON object,
which is the shape the compiler's own --json diagnostics have. Beside them,
nish/crypto/* holds the primitives under TLS 1.3 (SHA-2, HMAC, HKDF,
ChaCha20-Poly1305, AES-GCM, X25519, P-256 ECDSA, X.509) and nish/net/* the
protocols written on them (TLS 1.3, QUIC, HPACK, HTTP/1.1, WebSocket); the
std/ README lists every module.
import { Suite } from "nish/testing";
export const main = (): number => {
const t = new Suite("stats");
t.eqI32("sumOf", sumOf([3, 9, 4, 9]), 25);
return t.done(); // prints the report; 0 when nothing failed
};A library module is source, not a built artifact, so it compiles with the
program that imports it and the whole-program pass sees straight through it
(docs/wp21-packages.md): nish/<module> resolves to
std/<module>.ts beside the running compiler, and what you import but never
call is dropped at the link. What the library does not have is callbacks,
which is what makes a suite a value with methods rather than a
test("name", () => ...): a function is never a value here.
The suite's own golden cases are run by tests/nish/run.ts,
which is this repository's test harness written in the language it tests:
readdirSync finds the cases, spawnSyncTo captures each compile and each run,
and the IR is diffed against the golden line by line. npm run test:nish runs it
over the whole corpus.
tests/nish/cli.ts is the other half of that idea, pointed
at the compiler's own promises rather than at its output: --help on stdout with
exit 0 against the same text on stderr with exit 2, one flat --json object per
diagnostic with a stable code, and each documented exit-code band. It reads those
objects with std/json and takes the version it expects out of package.json, so
a release cannot leave the expectation behind (npm run test:cli runs it
against build/nish).
There is no garbage collector and no free, so the bugs that need one cannot
be written. What is left — an index out of range, a null dereference — is
checked, and every way to give a check up is a flag you pass or a builtin you
call on purpose.
| Bug class | What Nish does | Reference |
|---|---|---|
| Use-after-free, double free | Not expressible: nothing is freed individually. Four compile-time mechanisms decide where a value lives — a stack alloca when escape analysis proves it dies with the frame, an automatic arena scope when a function's temporaries do, a nish_arena_keep reclaim at the call site for a returned string, the bump arena otherwise — and the arena goes back when main returns |
Memory model, wp6-memory.md |
| Out-of-bounds read or write | Every a[i], a[i] op= v, s.charCodeAt(i), s.slice(a, b) and a.pop() is bounds-checked, with an unsigned compare, so a negative index fails too; the failure prints index out of range: <i> >= <len> (or, for slice, slice out of range: [<a>, <b>) of length <len>) and exits 1. Where a flow-sensitive proof shows the index is already in range — a loop condition, a length guard, a hoisted const n = a.length, an unsigned index — no check is emitted at all, and the checks that survive inside a loop say so as a performance warning naming the guard that would remove them |
Element access, tests/cases/arr_bounds_panic, arr_bounds_proven |
| Null dereference | T | null is a separate type, for pointers only; member access needs a narrowing the checker accepts and ?. is forbidden — which is what lets the emitter put nonnull dereferenceable on every pointer that is not one |
Nullable types |
| Uninitialised memory | new Array<T>(n) zero-fills and rejects pointer element types, because a zeroed pointer would be a null nobody declared; class fields are definitely assigned |
Classes, tests/cases/arr_new_zeroed |
| Unwinding past a release | There is none. Every function is nounwind; a failure a caller should handle is a Result<T, E> and one it should not is panic(message) — stderr, exit 1 |
Result |
This is where designs actually differ. Asked to place a value it cannot show
dies with its frame, Rust refuses to compile it, Go falls back to the garbage
collector, and Zig hands the question back to you and an allocator. Nish
leaves the value in the arena, where it stays until main returns.
So src/escape.ts and the whole-program fact
fixpoint are optimisations and nothing else: a refusal costs memory and never
correctness, and no program is rejected for a lifetime reason. The compiler
says so out loud where the cost is real: assigning an allocation to a local
that already holds one drops the old value where nothing can free it and
costs the whole function its arena scope, and that is a performance
diagnostic naming both rewrites (Diagnostics, on by
default, never fatal). That is the
trade the whole memory design rests on — the arena discipline a compiler pass
or a frame loop would otherwise be written around by hand, moved into the
compiler. tests/cases/mem_* pin the placements, and Arena.used() either
side of a 100000-iteration loop is how the suite checks that a scope really
does recycle.
Not any one of them; it is more useful to say which piece came from where.
| Concern | Closest to | Not |
|---|---|---|
| Lifetimes | Go's escape analysis, over a Zig-style arena discipline the compiler writes for you | Rust: no ownership, no borrow checker, no lifetime annotations |
| Bounds and panics | Rust with panic=abort |
C |
| Null | Kotlin and C# nullable reference types: flow narrowing, not a wrapper type | Rust's Option<T> |
| Errors | Rust: Result<T, E>, and orReturn() is ? |
exceptions, or Go's second return value |
| Signed overflow | Rust's debug profile: a checked panic, in every build, unless the compiler proves the operation fits; Rust's wrapping_add as an imported call to opt out at one site |
C, where it is undefined |
| Syntax | TypeScript |
The older relative is region inference as in Cyclone and MLKit: regions the compiler infers, bracketed by a mark and a release, which is exactly what the arena scopes and the call-site reclaim are. This version is deliberately weaker: one global arena, per-function granularity, no region polymorphism.
Caution
Three holes, every one of them asked for by name.
Arena.reset()/Arena.release(m)release or recycle in O(1), and doing either while anything allocated after the mark is still referenced is undefined behaviour. The compiler protects its own marks — a function that touches either, directly or through a callee, never gets an automatic scope — and not yours. Both are deprecated (NL7001) in favour ofusing a = arena(), the same release with the compiler refusing anything that would outlive it (Arena).uncheckedGetanduncheckedSetfromnish:unsaferead and write an element with no bounds check, after which an out-of-range index at that site is undefined behaviour. A module reaches them only through the import, so every module that gives up a check says so at its top and every site is a call (tests/cases/unsafe_get_set). That is a different thing from the proof above: the proof removes a check the compiler showed was never going to fire, and changes nothing about what the program means. The deprecated--unchecked-indexingdoes the same to every index of your own package and never reaches a dependency ornish/(nish:unsafe).- Interop hands a pointer to a C, wasm or N-API host, and what happens to it there is the host's business (wp8-interop.md).
Memory-safe like Go, allocated like Zig, errors like Rust, nulls like Kotlin, overflow checked like Rust's debug builds — with two C-shaped holes you have to ask for by name. What it buys is the output: no GC, no runtime, and the sizes under Performance and binary size.
nish <entry.ts> [more.ts ...] [options]
nish run [options] <file.ts> [args ...]
nish --version | --help
-o, --output <file.ll> output path for a single module (default: <input>.ll)
-o, --output <dir>/ output directory: one <dir>/<module>.ll per module
--link <exe> build a native binary from every module + runtime/runtime.c
(entry module must declare `export const main`)
--profile speed|size|debug|wasi
build profile for --link (default: speed; wasi: a wasm32-wasi module)
--fix apply every machine-applicable fix to the named files, then report
what is left (writes no IR)
--no-strict-exports every function is an external symbol (default: non-exported
functions get `internal` linkage)
--number-mode i32|f64 lowering of `number` (default: i32)
--plain no performance attributes or alignment hints
--runtime-decls always emit the runtime ABI prelude (arena + strings)
--emit-header <file.h> also write a C header for the callable functions
--emit-dts <file.d.ts> also write TypeScript declarations for the wasm exports, plus
<file>.mjs, a loader that marshals typed arrays
--emit-napi <shim.c> also write an N-API shim (build with --profile napi)
--emit-napi-async <shim.c> the same shim plus a promise-returning form of each export, run off
the event loop
--emit-panics <file.json> also write every panic site, per function, proven or not
--deny-panics refuse every panic site of the entry package a proof does not remove
--deny-retention refuse arena memory the entry package keeps after nothing can reach it
--emit-arena <file.json> also write every allocation site and where its memory goes
--emit-capabilities <file.json>
also write the capabilities (fs, net, clock, ...) each function reaches
--capabilities print the program's capabilities in one line on stderr
--allow <cap>[,<cap>...] refuse a program whose entry reaches a capability not listed
--deny <cap>[,<cap>...] refuse a program whose entry reaches a listed capability
--unchecked-indexing deprecated: drop the bounds checks of the entry package's modules
(use uncheckedGet/uncheckedSet from nish:unsafe)
--target <triple>|host emit `target datalayout`/`target triple` for that machine
(x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, x86_64-apple-darwin,
aarch64-apple-darwin, wasm32-unknown-unknown, wasm32-wasi); default: target-neutral IR
--wrapping deprecated: signed add/sub/mul of the entry package's modules wrap
(default: an operation not proven to fit is checked and panics on
overflow; use wrappingAdd/wrappingSub/wrappingMul from nish:unsafe)
--no-stack-alloc keep every allocation in the arena (disables escape-analysed allocas)
--threads give every thread its own arena and random seed; the runtime is
built to match by --link (importing nish/threads turns it on)
--no-warn-performance do not report the `performance` diagnostics (they are on by default,
print on stderr, and never change the exit code)
--warn-portability report the `portability` diagnostics: where the program's TypeScript
reading answers differently (off by default; never changes the exit code)
-g emit DWARF debug info (!dbg locations, variables); kept by --link
--json print diagnostics as one JSON object per line on stdout (no excerpt)
--emit-ast print the syntax tree of every module to stdout instead of IR
--emit-checked print the checker's tables (signatures, locals, structs, facts) instead of IR
-v, --version print the nish version and exit
-h, --help print the usage on stdout and exit 0
Exit codes: 0 success, 1 compile error (file:line:col: error: ... plus
a caret excerpt), 2 usage error, 3 toolchain error, 70 internal
compiler error (please report it; NISH_DEBUG=1 adds the stack trace).
A compile that fails reports every error it found (statement by statement,
declaration by declaration), in source order, up to 20 before ...and N more errors; --json gives editors and tools the same list as
{"file","line","column","endLine","endColumn","severity","code","message"}
objects, one per line, where code is a stable identifier for the rule
(NL1013, NL2231) and is what to match on rather than the prose. Failures
with no source position — an unusable C toolchain, an internal error — are JSON
objects too, so --json never leaves a caller with an empty stdout.
--help prints on stdout and exits 0; only a usage error goes to stderr
with 2. -g adds a DWARF line table and variables to the IR so
gdb/lldb step through the .ts source of a --linked binary
(docs/wp10-ci.md).
Multi-file programs: nish examples/multi/main.ts --link build/multi && ./build/multi; echo $?
prints 49.
nish run is the scripting shape. nish run tool.ts a b compiles
tool.ts, links it into a cache, and starts it with a b as its arguments.
Its stdin, stdout and stderr are the program's, and its exit status is the
program's once it starts (128 + n for a signal). The options before the file
are the compiler's (--number-mode f64, --profile speed, -g), and
everything after it is the program's, --help included. Nothing is
interpreted: every run compiles, which takes milliseconds, and links only when
the IR, the recipe or the runtime is new, so the first run of an edit pays for
one --profile debug link (the default here, and the fast one) and every run
after that starts the cached binary. The cache is $XDG_CACHE_HOME/nish/run,
or ~/.cache/nish/run. Each entry is one program, so rm -rf of it is always
safe. Beside it, nish/runtime keeps the C runtime compiled once per profile,
-g, --threads and C compiler, so a link (a run's or --link's) compiles
only the program's own modules, each in parallel; removing it costs one
recompile of the runtime. -o, --link, --target, the --emit-* sidecars and
--profile wasi write something a run keeps to itself, so they are usage
errors there, and performance warnings are not printed, because stderr belongs
to the program.
Without --link, build the IR yourself: clang add.ll examples/main.c runtime/runtime.c runtime/runtime-os.c -o app
(a program that uses threads, the host calls or nish:net also names
runtime/runtime-parallel.c, runtime-host.c or runtime-net.c; the
overriding the module target triple warning is harmless: the IR is
target-neutral unless you pass --target; -Wno-override-module silences
it), or step by step with llvm-as, llc -O2 -filetype=obj, and
opt -S -O2 to watch mem2reg turn the alloca locals into registers.
opt and llc only vectorise when the module carries a data layout, so
pass --target host (or a triple) when you inspect optimised IR by hand;
--link never needs it because clang supplies the layout. Cross-compile
with --target aarch64-unknown-linux-gnu / wasm32-wasi plus
clang --target=..., or with llc -mtriple=....
Rust-class output is the goal: no GC, no embedded engine, aliasing and
purity facts handed to LLVM up front, and a link step that strips everything
unused. examples/add.ts + examples/main.c + the runtime
(runtime/runtime.c, which scripts/build.sh pairs with the other four units), x86_64 Linux, glibc
dynamically linked (npm run size-report):
| Profile | Bytes | What it does |
|---|---|---|
debug |
15,072 | clang defaults: no optimisation, symbols kept. |
speed |
4,528 | -O3 -flto, section GC, unwind tables off, stripped. Rust --release. |
size |
4,512 | -Oz -flto, plus hidden visibility and no stack protector. Rust opt-level="z". |
wasm |
279 | Freestanding wasm32 module, every function exported, stripped. |
wasi |
42,228 (argv.ts) |
wasm32-wasi command module: the runtime linked against wasi-libc, _start runs main; needs a WASI sysroot (INSTALL.md). |
hello.ts with --link is 4,712 bytes. The runtime is five translation units
with a measured ceiling each, so that the core does not grow every time the
language reaches further into the operating system, and section GC drops
whatever a program does not call: runtime.c is the 3,606 bytes every program
touches (one chunked bump arena with O(1) reset and mark/release, strings,
JavaScript-exact number formatting, string parsing, Math.random,
process.argv, array growth, the panic paths), runtime-os.c the 1,530 bytes
that wrap a system call (exit, files, directories, subprocesses, the
environment, the clock), and runtime-parallel.c, runtime-host.c and
runtime-net.c hold threads, the host calls (wall clock, entropy, signals) and
nish:net's sockets — see
docs/wp7-runtime.md for the
budgets and the reasoning. Math.* calls are LLVM intrinsics, so pure functions
stay readnone.
Memory is the part that usually costs a compiled-JavaScript design its
speed, so it is done statically (docs/wp6-memory.md):
a new, object literal or array literal that provably never outlives its
function is an alloca (LLVM's SROA then turns its fields into registers);
what does reach the arena is bumped inline (a load, an add, a compare and a
store); a function whose arena temporaries all die with it brackets its body
with nish_arena_mark / nish_arena_release, so hot loops keep the arena
flat. Every LLVM attribute the compiler emits (nounwind, willreturn,
readnone/readonly, noundef, zeroext, nonnull, noalias,
nocapture, dereferenceable) is a proved guarantee, never a hint; the
rules are in docs/ARCHITECTURE.md.
The benchmark suite (bench/: fib, n-body, spectral norm, sieve, string
building, a Vec3 method loop, each in Nish, C and Rust with identical
algorithms and a shared checksum) is run by node bench/run.mjs, which
writes docs/BENCHMARKS.md: wall time, binary size and
peak memory per column, plus the exact build commands. The analysis of every
gap, and what nsw and PGO (scripts/build.sh --pgo-generate /
--pgo-use) bought when overflow was still undefined, is in docs/wp9-optimisation.md;
the rules of the game are in bench/README.md.
Nish never embeds a JavaScript engine (see the FAQ). The supported direction is Node importing Nish:
[ Node / Bun process ] imports [ Nish .wasm or .node addon ]
I/O, HTTP, npm packages math, parsing, data transforms, hot loops
cross the boundary once per batch, not once per element
scripts/build.sh --profile wasmproduces a moduleWebAssembly.instantiateloads directly (examples/node-host.mjs); exports use the plain C ABI. Addruntime/runtime-wasm.c(arena + arrays, no libc) when a function takes or returns an array.--emit-napi+scripts/build.sh --profile napibuild a.nodeaddon with argument type checks (examples/node-addon.mjs).- Buffers cross as typed arrays: an Nish
Int32Array/Float64Array/BigInt64Arrayparameter (the spellings ofi32[]/f64[]/i64[], one layout) is a JS typed array on both paths. The addon borrows it (zero-copy; writes are visible in JS), the wasm loader that--emit-dtswrites next to the.d.tscopies it into the module's memory and results back out; strings cross the addon as copies (examples/arrays.ts). --emit-headerwrites C prototypes (int32_t add(int32_t a, int32_t b);,double sumF64(const nish_array *xs);) next toruntime/nish.h, the public runtime ABI (arena, strings, arrays,nish_reset_arena,nish_arena_mark/nish_arena_releasefor a host that manages batches).- An N-API call costs about 30 ns and a wasm call about 2 ns before any work
is done; one call with a 1M-element
Float64Arrayruns at 0.5 ns/element (node bench/ffi.mjs), so pass whole buffers, not elements.
Details: docs/wp8-interop.md.
src/ is an Nish program, so the compiler compiles itself to
WebAssembly like any other one:
nish src/compile.ts --link web/nish.wasm --profile wasi
node web/compile.mjs web/nish.wasm examples/add.ts # the IR, from a Web WorkerThat module is about 480 KB (140 KB gzipped) and lexes, checks and emits IR
with no server in the loop; web/index.html is a playground built on it and
web/wasi.mjs is the in-memory filesystem it runs against. It stops at the
IR — clang and wasm-ld are not in a page — so --link and --profile are
refused there. web/README.md has the rest.
src/ is the compiler, and the only one: lexer, parser, checker and emitter,
written in Nish, with no typescript package underneath. It compiles its own
source to a fixed point:
IR(stage1, src/) == IR(stage2, src/) byte for byte, and stage3 == stage2
The chain starts from a seed, the previous release's nish binary, the way
Rust and Go build themselves: stage1 is src/ built by the seed, stage2 is
src/ built by stage1 and is what gets installed, and stage3, built by
stage2, has to reproduce stage2 file for file. scripts/bootstrap.sh --verify
asserts both equalities, and npm test re-proves them on every run. Compiling
the whole compiler costs it 91 ms and 86 MB; the TypeScript compiler it
replaced took 786 ms and 178 MB for the same input.
bash scripts/fetch-seed.sh # the last release, into build/seed/
npm run build # seed -> stage1 -> stage2 = build/nish
build/nish hello.ts --link hello # -o, --link, --profile, its own directoriesBecause the seed is the last release, src/ may only use in its own
source what that release compiles. A new construct is implemented in src/
and becomes usable inside src/ from the next release on — the rolling
freeze CI's bootstrap job checks. Until R6 there was a second compiler, the
TypeScript one, stage0, which seeded every bootstrap and served as the
oracle each phase of src/ was compared against; it was deleted once the
self-hosted compiler did everything it did
(wp19).
Details, and the subset src/ is written in, are in
docs/wp14-selfhost.md.
Pre-alpha, as above, and not for production use. The milestones below say what compiles, not what has been hardened: the one security audit so far was the project's own (docs/security/, with findings still open), and there is no compatibility promise. M4 is the milestone that freezes the language reference and tags a release. Until it lands, a construct's spelling, a flag's name and the IR any of them lowers to are all still free to change.
| Milestone | Contents | State |
|---|---|---|
| M1 "Programs" | pipeline prep, validator, control flow, strings, modules, CI | done |
| M2 "Data" | classes and interfaces, arrays, runtime and intrinsics | done |
| M3 "Rust parity" | interop, memory strategy (stack allocation, arena scopes, T | null), benchmarks with --target/--nsw (since removed)/PGO, differential testing against Node |
done |
| M4 "1.0" | frozen language reference, tagged release | not scheduled: the project stays on 0.x until its owner declares 1.0 |
| M5 "Self-hosting" | src/: the compiler, written in Nish, compiling itself |
done |
| M6 "One compiler" | the TypeScript twin deleted; src/ built by the last release, the seed |
done |
Not in the language yet, in the order they are likely to land: optional
reference counting for objects that must outlive an arena reset, and dynamic
dispatch — which would be a trait object over an interface, since inheritance
was removed (docs/wp25-inheritance.md) and every
method call names one symbol today.
Generic functions, classes, interfaces and methods compile — each
instantiation becomes its own specialised function or struct
(docs/wp18-generics.md) — while generic type aliases,
closures, try/catch and labelled break/continue are refusals
rather than gaps, each with the message and the idiom to use instead
(docs/LANGUAGE.md).
Release engineering (--version, exit codes, npm packaging, tag-driven
releases) landed with WP12; see CHANGELOG.md and
docs/wp12-release.md. The plan itself is
docs/MASTER_PLAN.md.
npm ci
bash scripts/fetch-seed.sh # the last release into build/seed/, once
npm run build # seeded bootstrap into build/nish (NISH_BOOTSTRAP=<nish> names the seed)
npm test # build + goldens, llvm-as, native round trips, runtime, layout, memory, interop, bench checksums, differential
node tests/run.js locals # only cases whose name contains "locals"
npm run test:update # write missing .ll goldens for new cases
npm run test:diff # every whole program natively and under Node (runtime/shim.mjs), compared byte for byte
node tests/differential/fuzz.js --stage1 --count 200 # random integer programs, the released seed's IR against HEAD's; prints the seed
npm run check # tsc --noEmit: an ambient type-check of src/, std/ and tests/nish/
npm run lint # Biome style lint (advisory, never a compile gate)
npm run smoke # build and run every example with a main
scripts/bootstrap.sh --verify # the whole chain, with IR(stage1) == IR(stage2) and stage3 == stage2 asserted
node bench/run.mjs # the benchmark suite; rewrites docs/BENCHMARKS.md (about 3 minutes)
docs/cookbook/regen.sh # refresh docs/IR_COOKBOOK.md; node docs/check-links.mjs checks the linksRead docs/ARCHITECTURE.md (the pipeline, side tables,
the add-a-construct checklist, ABI guard tests) and the conventions in
docs/MASTER_PLAN.md §7:
every construct ships with a golden .ll, an llvm-as pass, a native round
trip, a negative test, and its LANGUAGE.md and cookbook entries; no attribute
without a proof; layout changes touch src/runtime.ts and runtime/runtime.c together.
CI runs the suite on Ubuntu with LLVM 18, and the bootstrap and the CLI
contract on every platform a release ships a compiler for, macOS and ARM Linux
included (docs/wp10-ci.md). The documentation index is
docs/README.md. Coding guidelines for contributors and
coding agents are in AGENTS.md and .claude/.
Report a vulnerability privately, through GitHub's Security → Report a vulnerability, never in a public issue. SECURITY.md has the reporting channel, the supported versions and the threat model. docs/security/ holds the audit record of every area and the list of findings still open.
MIT, see LICENSE. Third-party code in the repository keeps its own licence; see THIRD_PARTY_NOTICES.md.