Compiling TypeScript to Native C++

arbayi1 pts0 comments

geatsc — the TypeScript-to-C++ compiler — GEA

Skip to content

There are two usual ways to put a modern interface on a small device. Write it in C by hand —<br>fast and tiny, but every screen is bespoke and nothing carries over to the next one. Or drag a<br>JavaScript engine onto the chip — familiar tools, but now you pay for an interpreter, a garbage<br>collector, and a memory budget most microcontrollers don't have to spare.

geatsc takes neither. It reads your TypeScript with the real TypeScript compiler ,<br>then lowers it to C++ that an ordinary toolchain builds into a native binary. Nothing interprets<br>your code at runtime. That buys you a smaller program, no eval, and performance you<br>can actually reason about.

geatsc in two minutes. A smart AC dial you change by rotating the screen,<br>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<br>small intermediate description of its components, reactive stores, JSX templates and CSS. Then<br>geatsc compiles that to C++, and a host C++ compiler turns the C++ into a binary<br>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<br>the reason you compiled in the first place. So the central rule is simple: a well-typed value lowers<br>to a native C++ type and stays one. An interface becomes a struct with real fields. A number is a<br>double or a long long. An array is a std::vector. The<br>dynamic carrier (gea_cpp_value) exists, but it is reserved for genuinely dynamic<br>boundaries — a thrown value, typeof on something unknown, an untyped<br>JSON.parse.

interface Forecast {<br>city: string<br>tempC: number<br>hours: number[]<br>↓ geatsc

struct __gea_type_Forecast {<br>std::string city;<br>double tempC;<br>std::vector hours;<br>};

That discipline pays off in places you'd normally just accept the tax. Annotate the shape of some<br>JSON and JSON.parse compiles to a one-pass typed decoder that reads bytes straight<br>into struct fields. No intermediate dynamic tree to allocate, walk, and throw away.

const f = JSON.parse(body) as Forecast<br>↓ 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<br>typed members, and a write updates the member, diffs it, and notifies subscribers only when<br>something is actually listening. No proxy object, no string-keyed property lookup left at runtime.<br>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<br>TypeScript on — and hand-written native C++, the speed-of-light ceiling. The harness runs the<br>same source through all three and checks the output is byte-identical before it trusts a number.<br>Against Node, the compiled binary is 2.5× faster overall — and about 4× on<br>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<br>at 0.69× the speed of hand-tuned C++ overall (0.9× on compute), produced from<br>TypeScript with no manual work. JSON stringify and the per-element number paths are the widest<br>gaps, and the concrete things to optimise next.

22×<br>less peak memory than Node — a ~1.4 MB floor

1.5 ms<br>cold start, vs Node's 9.7 ms

37–75 KB<br>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<br>order of magnitude, and that it comes from compiling, not from tuning.

Does it really run TypeScript?

"Real TypeScript"...

typescript native geatsc node compiler real

Related Articles