Compiling TypeScript to native C++
geatsc compiles real TypeScript — the whole language, not a subset — to native C++ ahead of time. No JavaScript engine ever touches the device. The binary is the app. Here is how it works, and what it costs.
There are two usual ways to put a modern interface on a small device. Write it in C by hand — fast and tiny, but every screen is bespoke and nothing carries over to the next one. Or drag a JavaScript engine onto the chip — familiar tools, but now you pay for an interpreter, a garbage collector, and a memory budget most microcontrollers don't have to spare.
geatsc takes neither. It reads your TypeScript with the real TypeScript compiler,
then lowers it to C++ that an ordinary toolchain builds into a native binary. Nothing interprets
your code at runtime. That buys you a smaller program, no eval, and performance you
can actually reason about.
The pipeline
A build runs in two compiler stages. First a Vite plugin bundles your app and emits a small intermediate description of its components, reactive stores, JSX templates and CSS. Then geatsc compiles that to C++, and a host C++ compiler turns the C++ into a binary for the target. In order:
- Parse and type-check. Your real
tsconfig, the actual TypeScript compiler API — parsing, module-graph discovery, the type checker, the same diagnosticstscwould give you. geatsc does not fork the compiler. It uses it. - Gate the static subset. A validation pass rejects what an ahead-of-time compiler can't honour —
eval,new Function, arbitrary dynamic shapes — with clear errors, before a single line of C++ is written. - Lower to C++. An AST-directed emitter turns types into native C++. A second plugin lowers JSX and real CSS into a typed node tree driven by signals. No virtual DOM.
- Build native. A host C++ compiler — clang, ESP-IDF, or emcc — links one native binary. Nothing on the device interprets it.
Types map to native types
Compile instead of interpret, then box every value into a dynamic carrier, and you have thrown away
the reason you compiled in the first place. So the central rule is simple: a well-typed value lowers
to a native C++ type and stays one. An interface becomes a struct with real fields. A number is a
double or a long long. An array is a std::vector. The
dynamic carrier (gea_cpp_value) exists, but it is reserved for genuinely dynamic
boundaries — a thrown value, typeof on something unknown, an untyped
JSON.parse.
interface Forecast {
city: string
tempC: number
hours: number[]
}
struct __gea_type_Forecast {
std::string city;
double tempC;
std::vector<double> hours;
};
That discipline pays off in places you'd normally just accept the tax. Annotate the shape of some
JSON and JSON.parse compiles to a one-pass typed decoder that reads bytes straight
into struct fields. No intermediate dynamic tree to allocate, walk, and throw away.
const f = JSON.parse(body) as Forecast
auto f = __gea_type_Forecast::__gea_json_parse(body);
Reactivity gets the same treatment. A reactive store keeps its TypeScript shape: its fields become
typed members, and a write updates the member, diffs it, and notifies subscribers only when
something is actually listening. No proxy object, no string-keyed property lookup left at runtime.
Generics lower the same honest way — to real C++ templates, not erased any.
Your interfaces become real C++ structs. The types you write are the types that run on the device.
How fast is the output?
The output is ordinary C++, so it has two reference points: Node — what you'd otherwise run the TypeScript on — and hand-written native C++, the speed-of-light ceiling. The harness runs the same source through all three and checks the output is byte-identical before it trusts a number. Against Node, the compiled binary is 2.5× faster overall — and about 4× on compute and object-heavy work, peaking near 16× on tight loops:
Hand-written native C++ is the harder test — the speed-of-light reference. Against it geatsc runs at 0.69× the speed of hand-tuned C++ overall (0.9× on compute), produced from TypeScript with no manual work. JSON stringify and the per-element number paths are the widest gaps, and the concrete things to optimise next.
Best-of-seven on one machine, byte-identical output across 26 of 27 fixtures. The point is the order of magnitude, and that it comes from compiling, not from tuning.
Does it really run TypeScript?
"Real TypeScript" is a claim you can check, so we hold ourselves to it. geatsc runs the TypeScript compiler's own test suites — the conformance and compiler trees, materially complete — alongside its own suite and a behavioural oracle. A recent full run:
ECMAScript runtime behaviour is held to Test262, the language's own conformance suite. That work is
in progress, and honest about its edges. Features an ahead-of-time compiler genuinely cannot run
(eval, new Function) are classified as skips, not quietly faked into the
pass column.
On real hardware
A compiler is only interesting if the result is usable on the silicon it targets. The same source builds for ESP32-class microcontrollers, embedded Linux, macOS and iOS. On a few-dollar ESP32-S3 with an AMOLED panel, a retained JSX app holds the panel's refresh ceiling, and a full-screen canvas map pans smoothly on an ESP32-P4:
What it won't do
The boundary is deliberate, and saying so plainly is part of the design. The one thing geatsc
won't compile is runtime code generation — eval and new Function.
Running code built from a string at runtime needs an interpreter on the device, and we won't
ship one to pretend otherwise.
Everything else compiles to native code today: classes, generics, unions,
async/await, Map and Set, closures, RegExp, Date, decorators,
generators, JSX and real CSS. Genuinely dynamic JavaScript — values whose shape isn't known
until runtime — is carried through a dynamic value type, and the full Proxy trap
surface works, with recognised reactive stores lowered to native field access for speed.
TypeScript, compiled to native
The same compiler builds the examples on this site for a microcontroller, embedded Linux, macOS and iOS, from one TypeScript codebase. The code is the best place to see it for yourself.