What is GEA · Engineering

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.

The compiler · part of the GEA stack

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.

geatsc in two minutes. A smart AC dial you change by rotating the screen, swiping or touch — written in TypeScript and CSS, compiled native to a rotary touch board.

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 diagnostics tsc would 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[]
}
↓ geatsc
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
↓ geatsc
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:

Speed-up vs Node.js — geometric mean per category, ×
Objects, arrays, calls4.40×
Compute & recursion4.33×
Overall2.49×
JSON parse1.26×
JSON stringify1.00×
geatsc vs Node.js. Best-of-seven on the same TypeScript source, byte-identical across 26 fixtures. Geometric means per category; tight loops peak around 16×.

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.

22×
less peak memory than Node — a ~1.4 MB floor
1.5 ms
cold start, vs Node's 9.7 ms
37–75 KB
binary, vs Node's ~112 MB runtime
The rest of the budget. Peak RSS (geomean), minimum cold start, and standalone binary size — Apple Silicon, node v24.8.

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:

14,172
cases passing
0
failing
490
skipped
TypeScript conformance. A standing rule forbids pinning a wrong answer as a "C++ deviation". Well-typed TypeScript must produce JS-spec-equivalent behaviour, or it's a bug.

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:

On-device frame rate — higher is smoother, fps
Bouncing balls~60
Map panning30–48
CSS 3D cube42
Measured on device. Bouncing balls is vsync-locked to the panel (~60 Hz). The CSS 3D cube's 42 fps is a hardware ceiling on this board, not a code limit. Map panning is over cached tiles 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.