A TypeScript-to-native compiler, written in Go, that emits LLVM IR and hands it to clang. You write .ts, it writes .ll, clang writes a real executable, your operating system is none the wiser.
Why does this exist? Not because TypeScript-to-native compilation needed solving. Microsoft's own research arm already tried this properly, with an actual team, an actual budget, and an actual production use case (Static TypeScript, compiling a restricted TypeScript subset to native code for microcontrollers), and this project has none of the above going for it. It exists because "how would I even build a compiler" is a much more fun rabbit hole than whatever I was supposed to be doing that day. The name is the actual mission statement. Κλάιν Μάιν (Klain Main) is Greek slang for "I don't care": build it anyway, for no better reason than "because I can." It has since grown a garbage collector's worth of features (including, as of -mm=gc, an actual garbage collector — opt-in, see below) and a small mountain of design-decision paperwork in docs/adr/.
⚠️ Personal / experimental project. One person, building this for fun, learning how compilers actually work by making all the mistakes smarter people already made, and already fixed, in better languages, ages ago. Not audited, not hardened, no stability guarantees between commits, and never destined for a production pipeline near you. It leaks memory on purpose by default (see below) and is enthusiastically fine with that. Perfect for tinkering, small CLI toys, and impressing exactly one (1) person at a dinner party. Bring your own garbage collector, or just pass-mm=gcand let it bring one for you.
The honest, itemized answer lives in docs/status/README.md: a feature-by-feature matrix, one page per feature area, with coverage percentages kept current as things ship — trust it over any claim here if the two ever disagree. It also has a "Fidelity Gaps" section for features marked done that still have real, non-cosmetic differences from actual JS/TS behavior worth knowing before you rely on them. Those pages are generated: the source of truth is docs/status/data/*.json, every coverage number derives from the row tables, and make status re-renders the Markdown (CI fails on any drift) — to change a status page, edit the JSON, never the .md.
Broad strokes:
- The language itself is basically all there. Classes, generics, closures,
async/await, the whole type-system circus (unions, generics with constraints, mapped and conditional types) — if it's core TypeScript, it probably compiles. Even the once-hard dynamic corners are in now:Proxy/Reflect, a real prototype-chain object model, and decorators (both the experimental and standard dialects, including factory@dec(...)calls — class-decorator replacement is the corner still open). The main holdout left iseval. - You can write a real server. Both the bespoke
http.listenand a realhttp.createServerspeak HTTP/1.1 and HTTP/2 (h2c) out of the same port, and you can run multiple concurrent servers in one process (plain, HTTPS, h2c, h2-over-TLS — each with its ownclose()/relisten and WebSocket/upgrade),fsreads and writes,worker_threadsandclustergive you actual OS threads and processes, and there's TLS on both ends. The networking stack —net,dns,dgram,tls,http2— is in.vmis the notable no. - The browser-shaped APIs that make sense off the browser all work.
fetch,URL,WebSocket, Web Crypto, Streams,AbortController, timers. The actually-browser-only stuff (DOM, Canvas, WebGL) is out, on purpose. - You can ship a desktop app, too.
import { Webview } from 'klain:webview'opens a real window over the system browser engine and calls straight into typed native code;new Webview({ serve: embedDir('./dist') })embeds a built SPA/SSG (React/Vue/Svelte/Quasar) into the binary and serves it from an in-binary server — a single-file desktop app, packaged to a.app/.desktop/GUI.exewith-package. macOS- and Windows-verified; Linux is compile-tier. - You can cross-compile to a phone.
--target sfos-aarch64 --sysroot …builds a CLI app for Sailfish OS (glibc Linux; aarch64 devices) and-package=rpm:harbourwraps it as an installable Harbour RPM (non-allowlisted libs likepcre2bundled automatically) — verified running on a real Xperia 10 II. A nativeklain:webviewGUI backend also works here:-webview=sailfishcross-builds a Gecko/embedlite-backed window (over the device'sRawWebView) that renders, round-tripsbindcalls page→native, and drives klain timers/microtasks from a native pump — verified on-device on the same Xperia 10 II. See "Cross-compiling for Sailfish OS" below. - It fuzzes itself. The lexer, the parser, and the whole parse-to-binary pipeline all have fuzz targets (
make fuzz/fuzz-codegen/fuzz-all) — the codegen one drives real source all the way throughclangand runs the result. - What'll bite you. A bare
: numberis a JS-faithful IEEE-754 double (0.1 + 0.2→0.30000000000000004, just like JS) — reach for a JSDocint8…uint64width when you mean exact machine integers. Concurrency is cooperative (one fiber at a time per thread, no preemption). The object model is two-tier: a statically-typed object is a fixed-shape struct (you can't bolt a new property onto it at runtime), while anany-typed value — or anything under-compat=js— gets the full dynamic bag (runtime property add/delete, prototype chain,Proxy). All deliberate.docs/status/has the honest corner-cases for everything above.
Every feature and bug fix comes with a matching entry in docs/adr/: a paper trail of what was tried, what broke, and why a given weird decision was made on purpose rather than by accident. Bigger features get scoped out in docs/tdd/ first: a design doc written before any code exists. Both indexes are cross-linked from docs/status/ wherever relevant, rather than repeated here.
Want to see it in action instead of reading about it? Every language feature has a runnable example under examples/: no README code snippets to go stale, just .ts files that actually compile and run (verified by make examples, every time).
Releases follow Semantic Versioning, applied automatically from Conventional Commit messages via GitHub Actions. See VERSIONING.md for the exact scheme and what still has to be true before this hits 1.0.0.
- Go 1.26+ (see
go.modfor the exact pinned version) clang(LLVM 15+, needs opaque-pointer support)libcurl, needed if the compiled program callsfetchorhttp.listen— the HTTP server's event loop links libcurl unconditionally so it can mergefetch's non-blocking transfers into the sameselect()loop, even in a server that never callsfetchitself. Every other program stays plain-libc, no extra install neededbdw-gc/libgc(the Boehm-Demers-Weiser garbage collector), needed only if compiling with-mm=gc—brew install bdw-gcon macOS,apt-get install libgc-devon Debian/Ubuntu,apk add gc-devon Alpine. The defaultmanualmode needs nothing beyond plain libclibpcre2-8/libpcre2-dev, needed if the compiled program usesRegExp(eithernew RegExp(...)or a/pattern/flagsliteral) —apt-get install libpcre2-devon Debian/Ubuntu,brew install pcre2on macOS,apk add pcre2-devon Alpine. Same conditional-linking convention aslibcurl: every other program stays plain-libc. See docs/status/REGEXP.md- a bigint backend library, needed only if the compiled program uses
bigint—libtommathby default (brew install libtommath/apt-get install libtommath-dev/apk add libtommath-dev), or GMP with-bigint=gmp(brew install gmp/apt-get install libgmp-dev). Same conditional-linking convention: a program without bigint stays plain-libc. See docs/tdd/TDD-00074.md - a crypto backend library, needed only if the compiled program uses
crypto.subtle— OpenSSL 3's libcrypto by default (brew install openssl@3/apt-get install libssl-dev/apk add openssl-dev), or Apple CommonCrypto + Security.framework with-crypto=commoncrypto(macOS only, ships with the OS — no install at all). Same conditional-linking convention: a program withoutcrypto.subtlestays plain-libc (crypto.getRandomValues/randomUUIDuse the OS CSPRNG directly, no library). See docs/tdd/TDD-00104.md - OpenSSL 3's libssl, needed only if the compiled program uses the
tlsmodule (tls.connect/tls.createServer) or aWebSocket(its URL scheme —ws://vswss://— is a runtime value, so any WebSocket program links libssl) — ships alongside libcrypto (brew install openssl@3/apt-get install libssl-dev/apk add openssl-dev).tlsis OpenSSL-only (-crypto=commoncryptois rejected for it). Same conditional-linking convention: a program that doesn't usetlsnever links libssl. See docs/tdd/TDD-00109.md - libuv, needed by every program with an event loop (timers with promises,
fetch, servers, child processes, fs.watch, Workers): the loop runs on libuv, as Node's does (brew install libuv/apt-get install libuv1-dev/apk add libuv-dev). A program with no asynchronous work never links it. See docs/tdd/TDD-00244.md - c-ares, needed by a program using the
dnsmodule: its resolver is Node's, over c-ares (brew install c-ares/apt-get install libc-ares-dev/apk add c-ares-dev). - libnghttp2, needed by a program that serves HTTP/2:
http2, andklain:http'slistenon its defaultnativebackend, which accepts cleartext HTTP/2 (h2c) beside HTTP/1.1 (brew install nghttp2/apt-get install libnghttp2-dev/apk add nghttp2-dev). A program serving neither never links it. See docs/tdd/TDD-00111.md - the wasmtime C API, needed only by a program using
WebAssembly: its modules compile and run on wasmtime (brew install wasmtimeon macOS; on Linux and Windows thewasmtime-v<version>-<arch>-c-apiarchive from wasmtime's releases, unpacked into/usr/localor anywhereWASMTIME_DIRnames). A program withoutWebAssemblynever links it. - libh2o (the libuv build), needed only for
klain:httpwith-http-backend=h2o(apt-get install libh2o-dev; no macOS or MSYS2 package, so those keep the defaultnativebackend). See docs/tdd/TDD-00255.md - a system webview, needed only if the compiled program constructs a
Webview(import { Webview } from 'klain:webview') — the desktop-window module compiles a vendored C++ binding over the OS browser engine. macOS needs nothing (WebKit.framework ships with the OS). Linux needs the WebKitGTK dev packages at build time (apt-get install libgtk-4-dev libwebkitgtk-6.0-dev, or the olderlibgtk-3-dev libwebkit2gtk-4.1-dev; Fedora:gtk4-devel webkitgtk6.0-devel) and the matching shared libs at run time. Windows needs only the WebView2 SDK header at build time (pacman -S mingw-w64-ucrt-x86_64-webview2-loader); it renders with the Edge WebView2 runtime that is part of Windows 10/11, and nothing ships beside the app. Same conditional-linking convention: a program without aWebviewnever links it.new Webview({ serve: embedDir('./dist') })(embedDirfromklain:assets) embeds a built SPA/SSG directory into the binary and serves it from an in-binary static server — a single-file desktop app with no externaldist/. See docs/status/DESKTOP-WEBVIEW.md
Windows 11 x86-64 is supported through the mingw-w64 UCRT toolchain from MSYS2 (the design and its caveats are in docs/tdd/TDD-00177.md). The compiler targets x86_64-w64-mingw32 and links against the same Universal C Runtime every Windows 10+ install already has, so compiled programs need no extra runtime. Install, in this order:
- Git for Windows (
winget install Git.Git) — the Makefile and the test scripts assume its Git Bash. - Go 1.26+ (
winget install GoLang.Go). - LLVM 15+ (
winget install LLVM.LLVM) —clangandlld, which links every program (without it the compiler falls back to MSYS2's GNUld, which cannot link a program of more than about 32,000 functions and literals). The compiler drives this clang with--target=x86_64-w64-mingw32 --sysroot=C:\msys64\ucrt64; setKLAIN_SYSROOTif MSYS2 lives elsewhere. - GNU Make (
winget install GnuWin32.Make, or MSYS2'smake). - MSYS2 (
winget install MSYS2.MSYS2), then in an MSYS2 shell:pacman -S mingw-w64-ucrt-x86_64-toolchainfor the C runtime headers and libraries, andpacman -S mingw-w64-ucrt-x86_64-{curl,sqlite3,pcre2,libtommath,openssl,nghttp2,libuv,c-ares,gc,zlib}for the optional libraries listed above (plusmingw-w64-ucrt-x86_64-webview2-loaderforklain:webview).
PATH matters on Windows: put C:\msys64\ucrt64\bin ahead of Git for Windows' mingw64\bin (Git's own curl has no HTTP/2, and compiled programs find the optional libraries' DLLs there at run time), and add C:\msys64\usr\bin for the coreutils (echo, printf, sh) the child_process tests spawn. A freshly installed tool is often visible only to terminals opened afterwards — or after a sign-out — on Windows. If a traffic-filtering product such as AdGuard is running, exclude 127.0.0.1 from it (or pause it) before running the test suite: it resets loopback TLS handshakes, answers HTTP/2 prefaces itself, and cuts large request bodies, which shows up as failing network tests that pass the moment it is off.
What differs on Windows, by design (Node behaves the same way there): process.platform is win32, os.EOL is \r\n, os.homedir()/os.tmpdir() read USERPROFILE/TEMP, fs.chmod only toggles the read-only attribute, fs.symlink needs Developer Mode or an elevated token, process.on('SIGINT') fires on Ctrl+C and process.on('SIGBREAK') on Ctrl+Break while process.on('SIGTERM') listeners are accepted but never fire (process.kill terminates unconditionally), and the process.getuid family does not exist. -package produces a GUI-subsystem <name>\<name>.exe with icon and version resources beside the console build. Known gaps: -crypto=commoncrypto stays out of scope, and there is no ASan/UBSan runtime for the mingw target in LLVM's Windows build.
- AddressSanitizer/UndefinedBehaviorSanitizer — no separate install on Linux and macOS: bundled with
clangitself, including Xcode's clang on macOS (confirmed directly on the Linux x86-64 box; not yet re-confirmed on Apple Silicon — see "Switching development machines" in the project's own instructions). On Windows MSYS2's UCRT64 clang has no sanitizer runtime; install the CLANG64 one (pacman -S mingw-w64-clang-x86_64-clang mingw-w64-clang-x86_64-compiler-rt), which sanitizer builds then use while linking the UCRT64 libraries.tests/compiler_test.go'sbuildBinaryASan/buildBinaryGCASanbuild a-fsanitize=address -fsanitize=undefinedbinary for a given source, for a specific investigation to call deliberately (not part of the regulargo test ./...run — ASan roughly doubles memory/time cost). Seedocs/adr/README.mdfor what had to be fixed in-mm=gc's allocator shim before this was actually usable. - Valgrind —
apt-get install valgrindon Debian/Ubuntu. On Apple Silicon (arm64) macOS, Valgrind has historically had no official upstream support —brew install valgrindmay fail outright or install a build that doesn't actually work; confirm directly before relying on it there rather than assuming parity with the Linux box. ASan/UBSan (above) work identically on both platforms and should be the default tool; reach for Valgrind only for the specific class of bug (e.g. conservative-GC-adjacent memory questions) where its instruction-level, allocator-agnostic instrumentation is worth the extra setup friction — and expect some false-positive "uninitialized value" noise from Boehm GC's own conservative stack scanning under Memcheck, a known pattern for conservative collectors, generally addressed with suppressions rather than code changes.
Every release ships a prebuilt klainmain for each platform whose test suite passed for that version — Linux x64/arm64, macOS x64/arm64 (Apple Silicon), Windows x64 — as klainmain-v<version>-<platform>[.exe] plus a checksums.txt on the GitHub Releases page. A platform whose lane was red is left out of that release and comes back with the next release that is green there (the install script says so if it cannot find your platform). The binary is the compiler only: it drives clang and links against the libraries in the Requirements section, which you install with the OS package manager.
# Linux / macOS → ~/.local/bin/klainmain (no sudo)
curl -fsSL https://raw.githubusercontent.com/memphisx/KlainMainLang/main/install.sh | sh# Windows → %LOCALAPPDATA%\Programs\klainmain\klainmain.exe (no elevation; added to the user PATH)
irm https://raw.githubusercontent.com/memphisx/KlainMainLang/main/install.ps1 | iexBoth scripts verify the SHA-256 from checksums.txt, print klainmain --version when done, and honour KLAINMAIN_VERSION=v0.64.0 (a specific release instead of the latest) and KLAINMAIN_INSTALL_DIR (another directory). klainmain --version prints the version stamped from the release tag; process.versions.klain in a compiled program is the same value.
# Build the compiler
make build # produces ./klainmain (stamped with `git describe`; `make dist` cross-compiles every platform into dist/)
# Compile a TypeScript file to a native binary (does NOT run it)
./klainmain examples/basics/basics.ts
# → produces examples/basics/basics
# Run the binary yourself
./examples/basics/basics
# Specify a custom output name
./klainmain -o myapp examples/basics/basics.ts
./myapp
# Compile and run in one step
make run FILE=examples/basics/basics.ts
# Inspect the generated LLVM IR (in case you, too, enjoy pain)
make ir FILE=examples/basics/basics.ts| Target | Description |
|---|---|
make build |
Compile the KlainMainLang compiler to ./klainmain (version stamped from git describe) |
make dist |
Cross-compile the release binaries for all five platforms into dist/ with checksums.txt (what the release workflow does, minus the per-platform test gate) |
make install |
Install to $GOPATH/bin |
make test |
Run Go unit tests |
make test-par |
Same tests, sharded across SHARDS (default 4) parallel processes for a faster local pre-check — ~1.5–2× (the E2E suite is subprocess/IO-bound, so more shards just thrash); any failure is re-run serially so a parallel-unsafe test doesn't flake the run. make test stays the source of truth |
make examples |
Compile and run every example file (the closest thing this project has to a regression suite you can read) |
make compile FILE=f.ts |
Compile a .ts file to a native binary (does not run it) |
make compile-o FILE=f.ts OUT=name |
Compile to a named output binary |
make run FILE=f.ts |
Compile and run a single file |
make ir FILE=f.ts |
Emit LLVM IR only (no binary) |
make fmt |
Format all Go source |
make vet |
Run go vet |
make lint |
fmt + vet |
make fuzz [FUZZTIME=30s] |
Fuzz the lexer and parser |
make fuzz-codegen [FUZZTIME=30s] |
Fuzz the full parse→codegen→clang→run pipeline (slower per-iteration) |
make fuzz-all |
Run every fuzz target |
make conformance-fetch |
Clone/update the pinned Test262 corpus into .test262/ (idempotent, gitignored — see Test262 conformance) |
make conformance |
Regenerate the Test262 reports against the full corpus, both compat lanes, into docs/testing/strict/ and docs/testing/js/ (fetches first if needed) |
make shadow-report |
Compile the corpus comparing each old codegen type decider with the new front end, rank the disagreements into .shadow-out/SHADOW-REPORT.md, and fail on a regression since the previous run (see Comparing the old and new front end) |
make clean |
Remove compiler binary and compiled example artifacts |
The E2E suite and the conformance runner build, run and delete thousands of small native binaries: every test writes its IR, objects and an executable that live for seconds, and a full -compat=both conformance run does the same for tens of thousands of files, twice. It is not a speed problem (writing a test's artifacts takes about a millisecond against roughly a second of clang -O2); it is wear, which matters on a laptop whose SSD is soldered.
Set KML_SCRATCH to a directory and the test harness and tools/conformance put every throwaway write under it — the temp root (TMP/TEMP/TMPDIR, so t.TempDir, the compiler's build directories and clang's temporaries all follow), the shared cache of pre-compiled runtime C sidecars, and the conformance workdir (-workdir still overrides it). Add GOTMPDIR to move the go tool's own link output (the ~40 MB test binary) too.
tools/ramdisk/ wraps that around a RAM-backed volume — create, export both variables, run the command, tear down:
tools/ramdisk/run.sh -s 8192 -- go test ./... -timeout 80m # macOS (hdiutil) / Linux (tmpfs or /dev/shm)
tools/ramdisk/run.sh -s 16384 -- go run ./tools/conformance -compat=bothtools\ramdisk\run.ps1 -SizeGB 12 -Run "go test ./... -timeout 80m" # Windows, via OSFMount (winget install PassMark.OSFMount)In Docker, mount the scratch as tmpfs with exec (the tests run the binaries they build from it): docker run --tmpfs /scratch:exec,size=8g -e KML_SCRATCH=/scratch -e GOTMPDIR=/scratch ….
A RAM drive competes with the conformance runner for memory: its default worker count is CPUs−2, capped by the memory the OS can still commit (≈1.5 GiB per clang -O2 worker) — it prints the cap when it applies, and -workers overrides it. Without the cap, clang itself dies mid-compile on a full box and the report counts that as invalid IR.
Set KML_IR_SITES=1 and every line the compiler emits carries a ; @site file.go:N < file.go:M comment naming the codegen code that wrote it. When clang then rejects the module, its error comes back with an emitted by … line under it: from klainmain, from the test harness, and in the conformance runner, which also groups CLANG_ERROR results by emitting site (CLANG_ERROR: <message> @ emit_stmts.go:1427 < …) and keeps the annotated output next to each failing module under clang-fail/. It changes nothing when unset.
While the compiler moves to a binder and checker, codegen still decides types itself in a few places: inferExprType, resolveType, reliableGlobalType and inferUnannotatedReturnType. With KML_SHADOW=<file> set and a new pass registered, each of those answers is also asked of the new pass, and every disagreement is appended to the file as a JSON line: the decider, the node kind and position, both answers, and the fields that differ. make shadow-report does this for the whole corpus (the examples, tools and apps, Test262's language tests, and, when fetched, every Node and WPT conformance program as the conformance runner assembles it, in both lanes) and ranks the result into .shadow-out/SHADOW-REPORT.md, with each codegen rejection of a program the checker accepted. Each run keeps every unit's outcome in .shadow-out/units.tsv and fails when a unit clean in the previous run has a finding now; go run ./tools/shadow -reuse ranks an existing run again without compiling. A decider is replaced by the new pass once its rows are gone. Asking changes nothing in the emitted IR, and with the variable unset nothing is asked.
go test ./... is ~25 min on Windows and ~10 min on Linux. tools/testsel/ runs the E2E tests a change touches: py tools/testsel/batch.py file:typedarrays,symbol runs every test in tests/typedarrays_test.go and tests/symbol_test.go (the test files are the area map), or a regex over each test's source body; netbatch.py runs the network-named ones. See its README.
Tests whose expected output should be Node's use assertSameAsNode(t, src): the same TypeScript program runs here and under node --experimental-strip-types and the two stdouts must match, so no expectation is typed by hand. Such tests skip when Node isn't installed; pair them with an assertOutput twin when the expectation must also hold on a Node-less CI lane.
klainmain [flags] <file.ts>
--emit-llvm Emit LLVM IR to stdout and stop (do not compile)
-o <name> Output binary name (default: input path without .ts)
-lto Link-time optimization (ThinLTO): the program, its builtin
library and its runtime compile to LLVM bitcode and are
optimized together at the link (through lld on Linux when
installed). Slower to compile; the bitcode objects cache
apart from the native ones. Same behavior either way.
-g Debug build: the program and its runtime compiled with
-g -O0 for a debugger (lldb/gdb). The runtime's C carries
line info against sources kept beside its cached objects;
on macOS a <name>.dSYM is written beside the binary. The
generated program code itself has no source-level line
info yet. Debug objects cache apart from optimized ones.
--static Statically link the output binary, for a scratch/distroless
Docker image with nothing else in it. Linux only: run
klainmain itself on Linux to use this. macOS's linker has
no static-libc support at all (Apple ships no static
libSystem/crt0.o, by design), so klainmain refuses --static
immediately with an explanation rather than surfacing a
confusing linker error.
-mm <mode> Memory management mode: manual (default, Memory.free(x)
only — see docs/status/MEMORY-MANAGEMENT.md), gc
(Boehm GC: every allocation gets collected automatically,
needs bdw-gc/libgc installed — see Requirements above), or
auto (the compiler inserts free calls where its escape
analysis proves them safe: /** @free */ block-exit frees,
/** @owned */ last-use frees, plus automatic freeing of
provably-local unannotated values; Memory.free is a compile
error in this mode). Works identically on Linux and macOS,
no special linker flags needed either way.
-bigint <lib> BigInt backend library, linked only when a program uses
bigint: libtommath (default, public domain) or gmp (LGPL,
faster). Both give identical arbitrary-precision semantics —
the flag only trades license/speed. See docs/tdd/TDD-00074.md.
-crypto <lib> crypto.subtle backend library, compiled+linked only when a
program uses crypto.subtle: openssl (default — libcrypto
3.x, all platforms), commoncrypto (macOS only — Apple
CommonCrypto plus Security.framework, no OpenSSL
dependency) or cng (Windows only — the OS's CNG,
bcrypt.dll, no OpenSSL dependency). All give identical
Web Crypto semantics. See docs/tdd/TDD-00104.md and
docs/tdd/TDD-00177.md.
-http-backend <b>
klain:http server backend, used only when a program imports
klain:http: native (default — libuv TCP handles, a parser per
connection with llhttp's rules, h2c over nghttp2; every
platform) or h2o (libh2o's libuv build, Linux, libh2o-dev).
h2o buffers a request's whole body (up to 1 GiB) before the
handler runs, where native runs it at 10 MiB and streams the
rest. The handler contract is identical across backends. See
docs/tdd/TDD-00255.md.
-webview <b> klain:webview engine backend, selected only when a program
opens a webview window: system (default — the per-platform
system engine: WebKitGTK/WKWebView/WebView2), sailfish (a
Gecko/embedlite backend for cross-compiled Sailfish OS
builds — see "Cross-compiling for Sailfish OS"), or cef / qt
(opt-in Chromium/Qt backends, not yet built — selecting one
rejects cleanly). The bind/eval/serve contract is identical
across backends. See docs/tdd/TDD-00144.md.
-compat <m> Compatibility mode (see docs/tdd/TDD-00075.md): strict
(default — the compiler's opinionated, safer-than-JS
semantics; e.g. a declaration colliding with an ambient
built-in like Math/fetch is a compile error) or js (best-
effort JS-faithful — e.g. real-JS/browser global shadowing).
Constructor-style Map/Date/RegExp stay reserved either way.
-no-unused-locals, -no-unused-parameters
TypeScript's noUnusedLocals and noUnusedParameters: a
local, import or private member (resp. a parameter or type
parameter) that nothing reads is a compile error with tsc's
code (TS6133 and its group forms). A name starting with _ is
exempt where tsc exempts it. Strict lane only; ignored with
a warning under -compat=js.
-regex <m> RegExp dialect: es-unicode (default) / ecmascript / es-utf16 /
es-ascii / pcre. See docs/tdd/TDD-00067.md.
-dynamic-import <m>
import() lowering. bundled (default): the target is
compiled into the binary, runs on first import() and
shares every module instance with the program, as in
Node. lazy: the same, with each target's code in a shared
library in <binary>.d/, loaded on first use. isolated: each
target is its own program in <binary>.d/, with its own
instance of every module it imports, so nothing it imports
shares state with the importer. lazy and isolated need the
<binary>.d/ directory beside the binary and refuse
--static.
-lib-objects Compile each builtin library module once into the object
cache and link the cached objects (default true). With
-lib-objects=false the library is compiled with every
program.
-diagnostics <f>
How errors in the program are reported: text (default —
one `file: line:col: message` line per error; parsing
continues past an error, so every syntax error in a file is
reported) or json (an array on stdout, one object per error
with its code, severity, file, line, col, byte range,
message, JavaScript error kind and phase). A code below
90000 is TypeScript's for the same condition.
-decorators <d>
Decorator dialect: experimental (the legacy
TypeScript/reflect-metadata semantics; the default for
TypeScript) or standard (the TC39 Stage-3 dialect; the
default with -compat=js).
-emit-decorator-metadata
Under -decorators=experimental, emit design:type/paramtypes/
returntype metadata for decorated declarations (DI-style
reflection).
-optimize-memory
Allocation optimizations with no semantic change:
stack-allocate object literals the escape analysis proves
never outlive their block, instead of heap-allocating
them. Off by default while the analysis matures. See
docs/tdd/TDD-00134.md.
-finalizers <d>
FinalizationRegistry exit diagnostics: off (default) or
report — under -mm=manual, print one labeled line per
registration still live at exit (its target was never
freed: a leak the program already named via the held
value) before running its cleanup callback. See
docs/tdd/TDD-00163.md.
--emit-window-dts
For a klain:webview program, also write a
<output>.window.d.ts declaring the window.* functions its
typed bindings expose, so the page-side SPA gets autocomplete
and typechecking on them (and a machine-checkable record of
the frontend<->backend contract).
--package After compiling, also build a package around the binary. Bare
--package builds a double-clickable host bundle (.app on macOS,
.desktop on Linux, a GUI-subsystem <name>\<name>.exe on Windows)
for klain:webview GUI programs. --package=rpm builds an RPM (for
Sailfish OS / RPM Linux) and --package=rpm:harbour applies
Sailfish Harbour naming (harbour-<name>) and bundles non-
allowlisted libraries (pcre2) app-privately; the .spec + build
tree are always written and rpmbuild is run when present, so an
RPM cross-package rides a --target build. The standalone binary
is still produced too. Metadata via --app-name / --app-id /
--app-version / --app-license / --app-icon.
--target <t> Cross-compile for another platform instead of the host: a clang
triple (e.g. aarch64-linux-gnu) or a preset — sfos-aarch64
(Sailfish OS on 64-bit ARM). 64-bit targets only: a 32-bit
triple (wasm32, i686, armv7) is refused. Requires --sysroot.
Cross-arch on the same OS works, as does macOS -> Linux (the
target's OS/arch is threaded through codegen); other cross-OS
pairs are rejected.
A cross-arch link needs lld on PATH. See "Cross-compiling for
Sailfish OS" below.
--sysroot <p> For --target: path to the target's root filesystem (its headers
and libraries), passed to clang as --sysroot. Required with
--target.
Run klainmain with no file (or klainmain --help) to print this list with
the full per-flag descriptions.
Every other compiled binary is dynamically linked (libSystem on macOS, glibc
on Linux, plus libcurl for fetch/http.listen, libnghttp2 for http.listen, and
libpcre2-8 for RegExp) — closer to typical C/C++ toolchain output than a self-contained Go
binary. --static closes that gap on Linux, verified end-to-end in real
scratch Docker builds (docker/Dockerfile*). One caveat: a fetch-using
static binary needs curl's entire dependency chain spelled out at link time,
plus a clang-then-gcc two-step on Alpine/musl — too distro-specific to
safely automate, so the full recipe lives in docs/adr/ rather than in the
compiler. RegExp's pcre2 has none of that drama and just links statically.
--static works on Windows too, and there it means self-contained: every
library the program uses is linked in — the mingw C++ runtime
(libstdc++/libgcc/libwinpthread, pulled in by the vendored Yoga layout
engine), pcre2 (RegExp), the full libcurl chain (fetch/http/tls;
ucrt64's curl uses Windows SSPI, not MIT krb5, so the static chain has no gssapi
drag), libuv, c-ares, sqlite, libtommath (BigInt), zlib, and Brotli and Zstd
(which a dynamic build loads only when used) — while the UCRT and core Win32
DLLs stay dynamic, since they ship with Windows 10/11. The result is a single
.exe that imports only Windows' own DLLs and runs on a box with no MSYS2
install (ADR-00771/00772, TDD-00253).
Without --static the build is dynamic, like Mac/Linux: the program needs those
DLLs bundled beside it or on PATH (a Windows app ships them; on Mac/Linux you
declare the dependency instead). Showcase apps are built --static (make apps).
Sailfish OS (Jolla) is glibc Linux; its modern devices are aarch64 (which we
target), so command-line apps cross-compile and package cleanly — verified building and running on a physical Xperia 10 II
(Sailfish 5.1.0.11): a plain CLI binary, a fetch/RegExp binary, and a Harbour
RPM the device's own rpm installs.
# On a Linux host (an arm64 Linux container runs natively on Apple Silicon).
# 1. Get a target sysroot (export the community builder image's rootfs — no emulation):
IMG=ghcr.io/sailfishos-open/docker-sailfishos-builder-aarch64:5.1.0.11
CID=$(docker create --platform=linux/arm64 "$IMG")
mkdir sfos && docker export "$CID" | tar -C sfos -xf - && docker rm "$CID"
# 2. Cross-compile + package as a Harbour RPM in one step:
klainmain --target sfos-aarch64 --sysroot ./sfos -package=rpm:harbour \
-app-name greet -app-license BSD-3-Clause -o greet greet.tsThe sfos-aarch64 preset uses Sailfish's native aarch64-meego-linux-gnu triple
(needed so clang finds the target's gcc/crt and RPM /usr/lib64 layout);
non-Harbour-allowlisted libraries like pcre2 are bundled under
/usr/share/harbour-<name>/lib with an rpath automatically. Full walkthrough:
the Cross-compile for Sailfish OS guide on the docs site.
The exported runtime rootfs above is enough for a libc-only or -lcurl/-lpcre2
CLI build. Linking a C++ shim that needs headers (Qt5 / qtmozembed /
sailfish-webengine, for a future -webview=sailfish GUI build) needs a -devel
sysroot instead — docker/Dockerfile.sfos-sysroot adds those packages to the
same builder image; build it and export its rootfs the same way (its header
comment has the exact commands).
A webview/GUI app cross-builds too, over Sailfish's Gecko engine
(sailfish-components-webview), with -webview=sailfish against a -devel
sysroot (build one with docker/Dockerfile.sfos-sysroot):
klainmain -webview=sailfish --target sfos-aarch64 --sysroot ./sfos-devel \
-o app app.ts # any klain:webview program — the bind/eval/serve API is unchangedThis produces an aarch64 binary linking the device's libsailfishwebengine /
libqt5embedwidget; the on-device windowed run is still being brought up.
Scope. The
klain:tui framework renders correctly on-device; the shipped klaintop example
runs on Sailfish by branching on process.platform — reading /proc on Linux
(portable across glibc/BusyBox) and BSD ps on macOS — the "write once, run
everywhere, no runtime" pattern the docs-site guide walks through. aarch64
first — it's what current devices ship, so it's where the initial support and
the on-device verification went. Sailfish also runs on x86_64 (the community Intel
ports); the --target machinery is generic, so that is a natural next step
rather than a boundary. Its 32-bit builds (armv7hl for the earlier ARM
hardware, and 32-bit x86) wait for 32-bit layouts: every value is laid out for
64-bit words today, so --target refuses a 32-bit triple.
Build host. A Linux host is the simplest — an arm64 Linux container runs
natively on Apple Silicon, and rpmbuild for -package=rpm is available there.
macOS → Linux cross builds also work directly (the target's OS/arch is threaded
through codegen, so process.platform, the fiber scheduler's context layout, fs
stat layouts, clocks, etc. all follow the target): the compiled binary links on the
Mac with lld on PATH (brew install lld) against the target --sysroot. Other
cross-OS pairs (a Windows target, or a macOS target from a non-macOS host) are
rejected; a klain:webview/klain:tui program is rejected cross-OS too (those C++
subsystems still build for the host — compile them natively on the target OS).
The conformance reports under docs/testing/ are generated, not hand-written — regenerate them, don't edit them. They're produced by running the full, unfiltered upstream tc39/test262 suite (53k+ files) through this compiler's own real pipeline (parser.Parse → llvm.NewEmitter/EmitProgram → clang, the same path tests/compiler_test.go uses), giving a real external conformance number rather than a hand-curated one. See TDD-00008 (Design V2) and ADR-00153 for the full design/investigation.
Each report is written once per -compat flag, into its own folder — docs/testing/strict/ and docs/testing/js/ — so each flag's history is independently diffable and a regression that only shows up under one flag is a clean per-file delta. Alongside Test262 there are two companion oracles regenerated the same way: make conformance-node (Node-core test/parallel behavioral suite) and make conformance-ts (the TypeScript accept/reject oracle). See docs/testing/README.md for the index.
make conformance-fetch # clone the pinned test262 commit into .test262/ (idempotent, gitignored — ~263MB, not vendored)
make conformance # regenerate docs/testing/{strict,js}/CONFORMANCE-RESULTS.md — both lanes, full corpus (fetches first if needed; ~13 min per lane, all CPU cores)Both targets are safe to re-run on a fresh machine (a new dev machine, after git clone, or after switching hosts per this project's own "Machine switch" practice) — make conformance alone is enough; it fetches on demand. tools/conformance/fetch.sh pins an exact commit SHA (test262 has no versioned release tags upstream) so re-running reproduces the identical corpus; tools/conformance/main.go walks it directly as a Go library (no dependency on the klainmain binary being built first) and needs only clang on PATH, same as everything else here. tools/conformance/harness-shim/ holds this repo's own compiler-compatible reimplementation of test262's shared sta.js/assert.js harness files (the real upstream ones use prototype-based pseudo-classes this compiler's type system can't represent) — every actual test file stays 100% unmodified.
Lexer → Parser (recursive descent, Pratt precedence climbing) → Module resolver → LLVM IR emitter → clang -O2 → a binary that runs on your machine, unsupervised
import/export exist, but don't expect a real linker anywhere in there. The module resolver parses every file your entry file imports, merges them all into one AST, and hands that to the emitter — one .ll, one clang call, one generated main(). An imported file's top-level code now actually runs (once, in dependency order, before whatever imported it); only files tangled up in an import cycle are still held to declarations-only, on the theory that circular modules reading each other's half-built state is a horror best left to languages with therapists on staff.
ast/ AST node definitions
codegen/
llvm/ LLVM IR emitter — ~60 small domain files, not a handful of
giant ones: emit_*.go per feature area, runtime_*.go for
the C-runtime decls + the libuv loop/ucontext fiber engine,
gc* for the -mm=gc shim. Full file-by-file map lives in
docs/ARCHITECTURE.md (not re-typed here to go stale)
docs/
adr/ Architecture Decision Records: one per feature/bugfix, numbered, never renumbered
tdd/ Technical Design Documents: scoping/design work for big features, referenced from docs/status/
status/ Implementation status: docs/status/README.md is a scannable index (coverage % + caveats per area), one page per feature area for the full detail. All .md pages are generated from status/data/*.json (make status) — edit the JSON, not the pages
testing/ Conformance results — generated, not hand-written; one folder per -compat flag (strict/, js/), each with the Test262 / Node-core / TypeScript-oracle reports; see "Test262 conformance" above
docker/ Dockerfiles verifying --static (+ fetch, + RegExp) actually runs in a scratch image, and -mm=gc actually runs on Linux/musl
.github/
workflows/ GitHub Actions: test + automated SemVer releases (see VERSIONING.md)
examples/ Sample .ts files: each compiles to a native binary, all wired into `make examples`
jsdoc/ JSDoc comment parser (@type annotations for the cases TS types can't express)
lexer/ Tokeniser
parser/ Recursive-descent parser with Pratt precedence climbing
resolver/ Module resolver: parses the entry file's transitive imports, merges into one AST
main.go CLI entry point
tests/ End-to-end tests (parse → IR → clang → run → assert on stdout), split by feature area; shared harness in tests/compiler_test.go
tools/
conformance/ Test262 runner — fetch.sh, main.go, harness-shim/; see "Test262 conformance" above
httpbin-lite/ Local HTTP fixture server backing `make examples`'s fetch/http examples (ADR-00096)
VERSIONING.md SemVer policy + the automated release mechanism
Makefile Build, test, and example targets
- Lex:
lexer.Tokenizeproduces a flat token slice. - Parse:
parser.Parsebuilds an AST; expressions use Pratt-style precedence climbing. - Emit:
llvm.NewEmitter().EmitProgramwalks the AST and writes LLVM IR text. The tricks the rest depends on:- Two-builder pattern:
allocas(entry-block allocas) andbody(everything else), merged at function end. freshReg()/freshLabel()mint unique SSA names; nothing is ever hand-numbered.- A scope stack for symbol resolution, plus a two-pass setup so functions can forward-reference each other.
- Arrays are
{ptr, i64}aggregates; objects are heap-allocated structs reached via GEP; closures are heap-allocated{funcPtr, envPtr}pairs; exceptions aresetjmp/longjmpwith a 64-slot jump-buffer stack;any/unknownare a boxed{tag, payload}pair with runtime-dispatchedtypeof/print/equality. ensure*()pattern: every C stdlib dependency (malloc,sscanf,gmtime, you name it) gets declared exactly once, the first time it's actually needed.- Concurrency, such as it is: one libuv event loop per thread (as Node's) merges the listening socket, every open
http.listenconnection, libcurl's own sockets (forfetch), child processes, pipes and the timer queue into one wait. Actual suspension isucontext.hfibers, not LLVM's coroutine intrinsics — a direct prototype proved coroutines segfault the moment atry/catchspans a suspend point, since this compiler'ssetjmp/longjmpexceptions assume a C stack frame that a coroutine's suspend unwinds out from under them. Fibers keep their own OS stack, so they don't have that problem. Within one thread there is no preemption: exactly one fiber runs at a time, cooperatively, same as JS's own single-threaded model. Real parallelism exists one level up —worker_threads'Workerspawns actual pthreads, each running its own independent instance of this same loop (every runtime singleton isthread_local), talking to the parent over pipe-based message channels.
- Two-builder pattern:
- Compile: the emitter writes a
.llfile next to the source, then shells out toclang -O2for the actual native codegen. KlainMainLang does the fun 90% and quietly lets a real compiler backend handle the part that would otherwise take a PhD.
- Collect garbage, automatically — by default.
manualmode (the default-mmvalue) never frees anything on its own (the one automatic exception: aPromise's slot gets freed the momentawaitreads it), andMemory.free(x)(seedocs/status/MEMORY-MANAGEMENT.md) is there if you want to free something by hand, C-style footguns and all. Left inmanualmode, your program's memory footprint is a monotonically increasing function of its runtime: a feature for short-lived CLI tools and a life choice for anything long-running. If you actually want automatic collection,-mm=gcopts into a real one (Boehm), or-mm=autohas the compiler insertfreecalls wherever its escape analysis proves them safe (no runtime collector, no dependency — it leaks conservatively where it can't prove safety) — see the CLI flags section above. - Grow a real linker.
import/exportexist (true per-file scoping, aliasing, and imported files now run their top-level code in dependency order), but there is no separate compilation and no link step: every module you touch gets flattened into one AST and onemain()behind the scenes. Turtles all the way down, except it's one big turtle. - Judge you for using
var. It even doesvarproperly now — function-scoped, hoisted, re-declarable — instead of the old cop-out of quietly pretending it waslet. Personal growth.
If any of that sounds like a dealbreaker, this was never going to be your compiler anyway, and that's fine. For everything it does do, docs/status/ has the receipts.
If you found this project while actually shopping for a TypeScript-to-native compiler rather than reading a stranger's compiler diary, you should know about PerryTS (github.com/PerryTS/perry) — a Rust project with the same core idea (TypeScript → SWC → LLVM → native binary) and a very large head start: a real team, native GUI on ~11 platforms, dozens of npm packages reimplemented natively, and shipped production apps. If you need something broad and battle-tested today, that is very likely the tool you want, and this section is not sour grapes — it's a signpost.
This project is deliberately a different, narrower animal. The honest head-to-head:
| KlainMainLang (this) | PerryTS | |
|---|---|---|
| Written in | Go, emitting LLVM IR text | Rust (SWC + LLVM) |
| Value model | static by default, NaN-boxed dynamic on demand — a statically-typed subset for typed code, plus a full NaN-boxed dynamic value model for any/unknown values and for everything under -compat=js: runtime property add/delete, a real prototype chain, Proxy/Reflect (the "D1" object model). Dynamic where you ask for it, static everywhere else |
NaN-boxed dynamic — dynamic by default; optional embedded V8 (--enable-js-runtime) for the untyped tail |
| Numeric types | a typed number is a raw JS-faithful IEEE-754 double, with opt-in sized machine types via JSDoc /** @type {int8…int64, uint8…uint64, float32} */ for exact width/signedness; an any-typed number is a NaN-boxed double, same representation as PerryTS's |
every number is a 64-bit double (NaN-boxed) |
| Semantics | opinionated, safer-than-JS by default (-compat=strict), JS-faithful mode opt-in (-compat=js) |
run all JS/TS faithfully |
| Memory | no GC by default (-mm=manual), Boehm GC (-mm=gc) or compiler-inserted frees via escape analysis (-mm=auto, no runtime collector) both opt-in |
generational GC by default |
| Tunable knobs | memory mode, compat mode, crypto/bigint/regex backends, link-only-when-used deps | few knobs, opinionated defaults |
| Targets | POSIX, host arch (CLI tools + Docker microservices + klain:webview desktop apps) |
~11 incl. mobile + native GUI |
| npm packages | none yet | 30–50 reimplemented natively |
| Maturity | one-person experiment | mature, many contributors, production apps |
Short version: PerryTS optimizes for breadth and running all the JavaScript; this project optimizes for a small, deterministic, statically-typed binary where you pick the trade-offs per compile. Different bets: reach for PerryTS to bring an existing JavaScript codebase to native; reach for KlainMainLang when you're writing fresh TypeScript and want a small, predictable binary that holds you to the type system.
Copyright (C) 2026 Kyriakos Bompotis.
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version (AGPL-3.0-or-later). See LICENSE for the full text.
It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. The AGPL's §13 additionally requires that anyone who runs a modified version to interact with users over a network make the modified source available to those users.
Parts of the project are derived from Node.js, @types/node and
TypeScript's library declarations, under their own permissive licences (MIT
and Apache-2.0); their notices, and which files they cover, are in
THIRD_PARTY_NOTICES.md.
As the copyright holder of the rest, the author reserves the right to offer the software under separate commercial terms as well; the third-party licences allow that for the parts they cover, provided their notices go with it.