Skip to content

About

A TypeScript-to-native compiler, written in Go

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

163 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

KlainMainLang

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=gc and let it bring one for you.

What actually works right now

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 is eval.
  • You can write a real server. Both the bespoke http.listen and a real http.createServer speak 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 own close()/relisten and WebSocket/upgrade), fs reads and writes, worker_threads and cluster give you actual OS threads and processes, and there's TLS on both ends. The networking stack — net, dns, dgram, tls, http2 — is in. vm is 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 .exe with -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:harbour wraps it as an installable Harbour RPM (non-allowlisted libs like pcre2 bundled automatically) — verified running on a real Xperia 10 II. A native klain:webview GUI backend also works here: -webview=sailfish cross-builds a Gecko/embedlite-backed window (over the device's RawWebView) that renders, round-trips bind calls 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 through clang and runs the result.
  • What'll bite you. A bare : number is a JS-faithful IEEE-754 double (0.1 + 0.2 → 0.30000000000000004, just like JS) — reach for a JSDoc int8…uint64 width 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 an any-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.

Requirements

  • Go 1.26+ (see go.mod for the exact pinned version)
  • clang (LLVM 15+, needs opaque-pointer support)
  • libcurl, needed if the compiled program calls fetch or http.listen — the HTTP server's event loop links libcurl unconditionally so it can merge fetch's non-blocking transfers into the same select() loop, even in a server that never calls fetch itself. Every other program stays plain-libc, no extra install needed
  • bdw-gc/libgc (the Boehm-Demers-Weiser garbage collector), needed only if compiling with -mm=gc — brew install bdw-gc on macOS, apt-get install libgc-dev on Debian/Ubuntu, apk add gc-dev on Alpine. The default manual mode needs nothing beyond plain libc
  • libpcre2-8/libpcre2-dev, needed if the compiled program uses RegExp (either new RegExp(...) or a /pattern/flags literal) — apt-get install libpcre2-dev on Debian/Ubuntu, brew install pcre2 on macOS, apk add pcre2-dev on Alpine. Same conditional-linking convention as libcurl: every other program stays plain-libc. See docs/status/REGEXP.md
  • a bigint backend library, needed only if the compiled program uses bigint — libtommath by 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 without crypto.subtle stays plain-libc (crypto.getRandomValues/randomUUID use the OS CSPRNG directly, no library). See docs/tdd/TDD-00104.md
  • OpenSSL 3's libssl, needed only if the compiled program uses the tls module (tls.connect/tls.createServer) or a WebSocket (its URL scheme — ws:// vs wss:// — 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). tls is OpenSSL-only (-crypto=commoncrypto is rejected for it). Same conditional-linking convention: a program that doesn't use tls never 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 dns module: 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, and klain:http's listen on its default native backend, 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 wasmtime on macOS; on Linux and Windows the wasmtime-v<version>-<arch>-c-api archive from wasmtime's releases, unpacked into /usr/local or anywhere WASMTIME_DIR names). A program without WebAssembly never links it.
  • libh2o (the libuv build), needed only for klain:http with -http-backend=h2o (apt-get install libh2o-dev; no macOS or MSYS2 package, so those keep the default native backend). 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 older libgtk-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 a Webview never links it. new Webview({ serve: embedDir('./dist') }) (embedDir from klain: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 external dist/. See docs/status/DESKTOP-WEBVIEW.md

Windows

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:

  1. Git for Windows (winget install Git.Git) — the Makefile and the test scripts assume its Git Bash.
  2. Go 1.26+ (winget install GoLang.Go).
  3. LLVM 15+ (winget install LLVM.LLVM) — clang and lld, which links every program (without it the compiler falls back to MSYS2's GNU ld, 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; set KLAIN_SYSROOT if MSYS2 lives elsewhere.
  4. GNU Make (winget install GnuWin32.Make, or MSYS2's make).
  5. MSYS2 (winget install MSYS2.MSYS2), then in an MSYS2 shell: pacman -S mingw-w64-ucrt-x86_64-toolchain for the C runtime headers and libraries, and pacman -S mingw-w64-ucrt-x86_64-{curl,sqlite3,pcre2,libtommath,openssl,nghttp2,libuv,c-ares,gc,zlib} for the optional libraries listed above (plus mingw-w64-ucrt-x86_64-webview2-loader for klain: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.

Debugging tools (optional, for chasing memory-corruption bugs)

  • AddressSanitizer/UndefinedBehaviorSanitizer — no separate install on Linux and macOS: bundled with clang itself, 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's buildBinaryASan/buildBinaryGCASan build a -fsanitize=address -fsanitize=undefined binary for a given source, for a specific investigation to call deliberately (not part of the regular go test ./... run — ASan roughly doubles memory/time cost). See docs/adr/README.md for what had to be fixed in -mm=gc's allocator shim before this was actually usable.
  • Valgrind — apt-get install valgrind on Debian/Ubuntu. On Apple Silicon (arm64) macOS, Valgrind has historically had no official upstream support — brew install valgrind may 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.

Install

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 | iex

Both 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.

Quick start

# 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

Make targets

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

Keeping test writes off the SSD

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=both
tools\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.

Tracing invalid IR to the emitter

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.

Comparing the old and new front end

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.

Running a slice of the suite

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.

CLI flags

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).

Cross-compiling for Sailfish OS

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.ts

The 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 unchanged

This 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).

Test262 conformance

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.

The pipeline, in one breath

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.

Project layout

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

How it works

  1. Lex: lexer.Tokenize produces a flat token slice.
  2. Parse: parser.Parse builds an AST; expressions use Pratt-style precedence climbing.
  3. Emit: llvm.NewEmitter().EmitProgram walks the AST and writes LLVM IR text. The tricks the rest depends on:
    • Two-builder pattern: allocas (entry-block allocas) and body (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 are setjmp/longjmp with a 64-slot jump-buffer stack; any/unknown are a boxed {tag, payload} pair with runtime-dispatched typeof/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.listen connection, libcurl's own sockets (for fetch), child processes, pipes and the timer queue into one wait. Actual suspension is ucontext.h fibers, not LLVM's coroutine intrinsics — a direct prototype proved coroutines segfault the moment a try/catch spans a suspend point, since this compiler's setjmp/longjmp exceptions 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' Worker spawns actual pthreads, each running its own independent instance of this same loop (every runtime singleton is thread_local), talking to the parent over pipe-based message channels.
  4. Compile: the emitter writes a .ll file next to the source, then shells out to clang -O2 for 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.

Things this compiler will cheerfully never do

  • Collect garbage, automatically — by default. manual mode (the default -mm value) never frees anything on its own (the one automatic exception: a Promise's slot gets freed the moment await reads it), and Memory.free(x) (see docs/status/MEMORY-MANAGEMENT.md) is there if you want to free something by hand, C-style footguns and all. Left in manual mode, 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=gc opts into a real one (Boehm), or -mm=auto has the compiler insert free calls 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/export exist (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 one main() behind the scenes. Turtles all the way down, except it's one big turtle.
  • Judge you for using var. It even does var properly now — function-scoped, hoisted, re-declarable — instead of the old cop-out of quietly pretending it was let. 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.

Prior art, and the obvious alternative

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.

License

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.

About

A TypeScript-to-native compiler, written in Go

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages