The Transpiler
Hal doesn't interpret your TypeScript — it compiles it: parse to an AST, build an IR, run the validators, emit C++, and hand the result to the toolchain. Source maps connect every emitted line back to yours, so C++ errors point at TypeScript.
type mapping
The TypeScript you write decides the C++ you get. Plain, predictable rules — nothing dynamic survives to the board, because there's nowhere to put it.
Example first
| TypeScript | C++ |
|---|---|
| number | int — widened to long long when values overflow |
| boolean / string | bool / const char* — no heap strings |
| uint8_t, int32_t, size_t... | Pass straight through — declare them in TS |
| Arrays | Fixed C arrays (Zephyr) or std::vector (native) |
| Objects | Anonymous structs — struct _config_t { ... } |
| Set / Map / Record | std::set / std::map |
| Unions | std::variant — | null drops the null side |
| ?? and ?. | Helpers that preserve 0/false and missing objects |
| Template literals | Fixed char buffers + snprintf with inferred format specifiers |
| enum-like unions | enum class : uint8_t — narrowed to the smallest fitting integer |
build-time directives
Escape hatches that disappear at build time. They're real functions when you type-check or test in Node — and become raw C++, an include line, or a board constant in the output.
Example first
import { include, rawCpp, rawCppExpr, board, callback } from '@typecad/hal';
include('hal/my_driver.h');
// statement form — emitted verbatim
rawCpp('my_driver_calibrate(&hdev);');
// expression form — usable inline
const id = rawCppExpr<number>('my_chip_id()');
// compile-time constants from the board manifest
const flashKb = board('boardDetails.flashKb');
// mark a function as an interrupt callback
callback(() => {
count++;
});board() board(path: string): unknown
| Parameter | Type | Units / values | Description |
|---|---|---|---|
| path | string | — | Dot path into the board manifest — 'boardDetails.flashKb', 'pins.all.13.capabilities.pwm', 'peripherals.i2c.count' |
Returns undefined in plain Node, a compile-time constant in the emitted C++ —
size buffers and branch on hardware facts without runtime cost.
what gets erased and promoted
The abstractions cost nothing at runtime — the transpiler removes them while keeping the program's meaning, and promotes what it can prove into cheaper code.
Example first
| Feature | What the build does with it |
|---|---|
| Pin handles | led.toggle() → gpio_pin_toggle_dt(&__tc_dt_led) — devicetree resolved at build time |
| Ownership types | Owned/Shared/Mutable erase — Shared just adds const; no wrappers, no refcounting |
| let → const | Whole-program analysis promotes never-reassigned lets to C++ const — ROM and constant folding |
| volatile | Globals shared with interrupts get volatile automatically |
| throw | Compiles to a deliberate halt loop on Zephyr — there are no exceptions |
| Tree-shaking | Unreachable code never reaches the .cpp — reachability from the entry point |
The validators
Every build runs the full validator set against the IR — the mistakes that would be a debug
session on hardware are build errors here. Off-board (simulator) or skipped with --skip-type-check, they're your call.
- Pin capabilities and pin-mode configuration
- Peripheral validation and peripheral ownership (take/release)
- Pin alias conflicts and unsafe pins
- Interrupt safety — which operations block inside an ISR
- Reentrancy detection for shared state
- ADC range checks against the board data
- Unit suspicion (millis vs micros class mistakes)
- Memory budget — statics + estimated stack vs the board’s RAM
- Blocking delays in loops
- Network usage — HTTP without a radio, weak AP passwords
- Ownership analysis — the Owned/Shared/Mutable checks
- Try/catch validation