A statically-typed, AOT-compilable embedded scripting language for native applications: a C-compatible execution and memory model wearing a TypeScript-subset syntax. Because the syntax is a subset of TypeScript, standard TypeScript editor tooling works against it; the compiler adds sound static types, C-ABI data layout, deterministic memory, and zero-copy C interop.
It is built for a host that owns its main loop and exposes a C ABI, and that wants user-authored logic to be fast to iterate on and predictable at run time.
subscript is a language project — not a JavaScript runtime, not a JavaScript binding.
Embedding a scripting language in a native application usually forces a choice:
- A dynamic embedded language (Lua, JS) — fast iteration and good tooling, but boxed values, garbage-collector pauses you do not control, and a marshaling layer between the script and the host's C structs.
- Native C/C++ — no marshaling and full control, but slow iteration (recompile-and-relaunch) and no safety net when the code is wrong.
subscript aims for the middle: the iteration speed and tooling of a scripting language with the data model and performance of C, and a sound type system that reports ordinary mistakes as early diagnostics at the source position.
Developers writing application, simulation, or tools logic against a native, C-ABI host that owns the loop — gameplay code in an engine, a DSP block in an audio plugin, a tool script in a content pipeline, a control step in an embedded system — who want:
- Fast iteration — a hot-reload development tier (edit a function body, see it swap at the next loop boundary) without giving up native performance when shipping. A changed function body reaches a running program in 0.33 ms, and a changed whole program in 3.6 ms; the shipping tier needs 114 ms to check, emit C, compile, and link the same program.
- Native ship performance — the shipping tier compiles to a native
binary. On the project's matrix-propagation gate it runs at 1.33× of
an equivalent hand-written C program (
clang -O2, same machine, same session); on compute-bound benchmark workloads it reaches 1.00×. The shipping tier emits C and hands it to the platform C compiler (LLVM/clang), so it inherits that compiler's optimization; the gap is what the language's safety semantics cost over hand-tuned C — a bounds check per element access, an overflow and divide check, an allocation header. - Editor tooling with no custom plugin — the syntax is a subset of
TypeScript, so
tsserver(completion, go-to-definition, inline errors) works unmodified against an ambient.d.tsprelude. Note thattsserverchecks the permissive TypeScript superset; the language's sound rules (integer types, value types, nominal identity) are enforced by subscript's own checker, not by the editor. - Zero-copy C interop — bind a C header and call it directly; the language's structs are the C structs (layout is machine-verified against the platform C compiler), so no data is converted or copied at the boundary.
- Deterministic memory — Context-scoped allocation, explicit
Context.free(value), andContext.collect()only when you ask for it. No collector runs unbidden, so there is no pause the host did not ask for — the property a frame loop, an audio callback, or a control step all need.
subscript deliberately gives some things up, and these are permanent, not gaps to be closed later:
- No npm / existing-TypeScript compatibility. Sound typing rejects the unsound patterns most published TypeScript is written against. Existing packages do not carry over.
- No JavaScript semantics. No
any, no prototype mutation, noeval, no implicitf64number. The accepted subset is defined by an executable corpus, not by JavaScript's spec. - Not a standalone program runtime. subscript is embedded: the host owns the main loop and calls exported functions, and platform capabilities (files, sockets, devices, threads) come from the host through its C ABI rather than from the language. The standard library grows in computation — numbers, strings, collections — while access to the outside world stays the host's to grant. That is a division of responsibility, not a capability ceiling.
- Not a sandbox. A script is first-party code, and the compiler spends its effort on early, precise diagnostics for honest mistakes rather than on containing hostile ones. A host that runs content it did not write adds an isolation boundary of its own.
- subscript for C and C++ developers — the language from the host's side, ending in a step-by-step embedding walkthrough (a complete host is 31 lines of C).
- subscript for TypeScript developers —
what changes coming from TypeScript: sized integers, nominal and value
classes,
nullwithoutundefined, explicit memory,Error-family exceptions beside uncatchable traps, host-steppedasync, coroutines, workers, and the complete rejection table. - subscript for Rust embedders — embedding through the crates directly: the dev tier in your process, a frame-loop host with hot reload in four steps, backed by a test-pinned example.
Every command and output shown was run against the repository as committed.
Every accepted program type-checks under stock tsc with the ambient
prelude — that is what makes the TypeScript editor tooling work. The
compiler then narrows: tsc accepts a superset, and subscript enforces
the sound rules on top (nominal types, sized integers, value types,
restricted unions, Error-family exceptions only). A program tsc cannot police is
rejected here with a rule-specific diagnostic at the TypeScript source
position.
@ValueType
class Vec3 {
x: f32;
y: f32;
z: f32;
constructor(x: f32, y: f32, z: f32) {
this.x = x;
this.y = y;
this.z = z;
}
}
function add(a: Vec3, b: Vec3): Vec3 {
return new Vec3(a.x + b.x, a.y + b.y, a.z + b.z);
}
export function main(): void {
const a: Vec3 = new Vec3(1.0, 2.0, 3.0);
const b: Vec3 = new Vec3(4.0, 5.0, 6.0);
const sum: Vec3 = add(a, b);
print(`${sum.x},${sum.y},${sum.z}`); // 5,7,9
}@ValueType class is a C-layout value type (copy-on-assign, copy-on-pass);
f32/i32/u32/i64/u64/f64 are sized numerics with C conversion
semantics; a plain class is a heap reference type with manual lifetime.
There is no default number type — a sized type is always required.
Every language-visible struct lowers to exactly the layout the platform C
ABI gives the equivalent C struct — no vtables, no name mangling. This is
not asserted; it is machine-verified: a test compiles a C header with
the platform C compiler and checks the language's computed
offsetof/sizeof/_Alignof against the compiler's own, field by field,
padding included.
The host presents C headers; subscript binds them. A generator reads a C
header and emits the ambient .d.ts mirror, and the compiler calls the C
functions directly — struct-by-value, (pointer, count) array pairs,
length-carrying string views, callbacks with void* userdata, and opaque
handles all cross the boundary with no conversion. No specific host header
is privileged by the language; if host data must become script-visible,
the host grows a C facade.
Memory is Context-scoped. Allocate objects normally, release finished
objects with Context.free(value), and call Context.collect() when you
want unreachable allocations reclaimed. Nothing collects unbidden — a
program that never collects is correct, merely larger — so there are no
collector pauses in the frame loop.
The tsc side is the ES2022 standard library, so the editor already knows
Math, Date, String and Array; subscript accepts a deterministic
subset of them with sized-type signatures and rejects the rest with a
clear diagnostic — tsc accepts more than the language does, never less.
What is in so far: Math (ECMA edge semantics, plus a seeded PRNG so
Math.random is replayable), a UTC-only Date that erases to i64
millis, the String and Array methods listed in the API reference,
map/filter/reduce/sort with real closures, Map/Set, typed
JSON against a class you declare, and regular expressions. Every
operation with a runtime component is implemented once and called
through an opaque symbol, so no tier carries its own copy. Every
accepted operation is deterministic given the Context, which is what
makes replay and the golden corpus possible.
An operation whose result depends on a locale table, on a random seed
the program does not control, or on the host's libc is either rejected
or made explicit.
- Development tier — an in-process JIT (Cranelift) with hot reload: a function-body edit is recompiled and swapped at a frame boundary; type or layout changes require a restart, and a coroutine suspended across a reload is invalidated with a clear trap.
- Shipping tier — ahead-of-time compilation to C, built with the
platform C compiler (LLVM/clang, or MSVC
clon Windows) at-std=c11 -O2. Ship targets: arm64 devices (iOS, Android) and the desktop hosts (macOS arm64, Windows x86-64, Linux x86-64). The development tier runs on the same three desktops.
A reference interpreter reads the same verified IR. It is written from the IR contract alone, so it shares no assumption with either tier.
The tiers are held to byte-identical output: a standing
differential gate runs every corpus program under the dev tier and the
ship tier and compares both against a committed golden, on every test
run. The interpreter runs the entries that need no host C library, as
the third witness: 176 of them in the debug profile, and 177 under
SUBSCRIPT_FULL_INTERPRETER_SWEEP=1 (62 entries declare an
exclusion, 56 of them for a foreign call). The language's behaviour
is defined by that corpus, not by any one backend.
Ten workloads, each implemented identically in every language and producing
the same integer checksum (the benchmark refuses to report a workload
unless all subjects agree — same computation, verified). Ratios are to a
hand-written C baseline; lower is better, C = 1.00×. One arm64 macOS
machine, one session. Every subject discards warm-up runs until measured
execution passes a 200 ms floor (at least three), then reports the median
of 11 timed runs; a subject whose interquartile range is wider than 15%
of the median is withheld as noise. Full table with absolute times, methodology, and
machine/runtime versions is in benchmarks/.
| Workload | C | subscript‑ship | subscript‑jit | LuaJIT | JSC | V8 |
|---|---|---|---|---|---|---|
| mandelbrot | 1.00× | 1.01× | 1.04× | 2.77× | 1.00× | 1.00× |
| fib-recursive | 1.00× | 1.01× | 2.21× | 1.93× | 1.49× | 2.63× |
| primes | 1.00× | 0.97× | 1.47× | 2.10× | 0.93× | 1.72× |
| fib-loop | 1.00× | 1.03× | 2.43× | 1.48× | 1.09× | 1.58× |
| queen | 1.00× | 1.09× | 1.51× | 1.51× | 1.23× | 1.79× |
| sort | 1.00× | 1.14× | 2.15× | 2.26× | 1.44× | 1.77× |
| tree | 1.00× | 2.00× | 6.33× | 2.17× | 0.32× | 0.47× |
| particles | 1.00× | 1.92× | 12.02× | 3.84× | 1.90× | 3.58× |
| collect | 1.00× | 1.08× | 3.56× | 3.66× | 0.94× | 2.70× |
| callbacks | 1.00× | 2.83× | 19.02× | 9.76× | 5.24× | 30.23× |
For what the language looks like at these speeds — twelve commented programs,
a C host facade, and a C host that owns the loop — see
examples/.
What the numbers show:
- On compute-bound work the shipping tier is C — mandelbrot
1.01×, fib-recursive 1.01×, primes 0.97×, fib-loop
1.03×, queen 1.09×. The shipping tier is the emitted C compiled
by the same
clang -O2, and pure-numeric code has almost no array traffic to check. - The cost is checked memory traffic and value copies —
sort(bounds-checked growable arrays) at 1.14×,tree(per-node allocate and free through the Context's size-class arena) at 2.00×,particles(value-struct arrays) at 1.92×. These are the language's real costs — an emitted bounds check per element, value-copy semantics, a 16-byte allocation header — not a measurement artifact. callbacks(2.83×) is the widest gap, and it is the idiom's:map/filter/reduceover a 1000000-element array 20 times allocates a fresh output array per stage, while the C baseline reuses three buffers it allocates once. A callback that names a function compiles to a plain loop with a direct call; the allocation is what remains. Every runtime pays for the idiom (JSC 5.24×, LuaJIT 9.76×, V8 30.23×).collectis near C. It allocates 20000 string-owning nodes per round, drops one quarter of them, and reclaims those through an explicit collection: 1.08× of C on the shipping tier, behind JSC (0.94×) and ahead of V8 (2.70×) and LuaJIT (3.66×).- Against the JITs, the shipping tier is ahead of LuaJIT on every row,
level with JSC on the compute-bound rows and on
particles, ahead of JSC and V8 oncallbacks, and behind JSC oncollect. JSC/V8 lead ontree, where garbage-collected bump allocation beats even C. - The development tier trades execution speed for iteration speed — the Cranelift JIT is tuned for compile speed and hot reload, not peak codegen, and runs 1.04×–19.02×. That is the trade the tier exists to make; the next section measures the side it is paid on.
Time from a changed source to a running program, on the same matrix-propagation gate, median of 11 timed runs:
| What changed | Development tier | Shipping tier |
|---|---|---|
| one function body (hot reload) | 0.325 ms | — |
| the whole program | 3.567 ms | 114.2 ms (4.4 ms check and emit C, 109.8 ms cc compile and link) |
The development tier reaches a running program 32× faster than the
shipping tier, and a hot reload of one function is 350× faster. On the
same gate the shipping tier executes at 1.33× of C and the development
tier at 19.7×. All four figures are gated: specs/blocks/compiler.md §3
requires the shipping tier within 1.5× of C, either kind of iteration
within 20 ms, and development-tier execution below a 25× ceiling.
This is one benchmark set on one machine; treat the ratios as indicative,
not a leaderboard. The table above is the arm64 / macOS snapshot (the
shipping target), captured 2026-09-27 at f682926. The shipping tier's tree median
has two modes on one binary (about 1.55× and 2.01× of C in consecutive
runs); this snapshot caught the upper one. An x86_64 / Windows snapshot
(2026-09-27, at 246c7dd) is in
benchmarks/README.windows-x86_64.md.
It has four subjects, because LuaJIT and JSC are not built there. The runner
withheld one cell as noise (tree C), so the tree row has no ratios.
TypeScript-subset source
→ parse (SWC)
→ semantic checker (sound narrowing; rule-specific diagnostics)
→ typed HIR
→ LIR (one ordered IR: evaluation order, control flow, liveness,
trap sites as data; verified before any consumer reads it)
├─ dev tier: LIR → Cranelift JIT (hot reload)
├─ ship tier: LIR → C → platform C compiler (AOT)
└─ reference interpreter over LIR (the third witness of the gate)
all over one runtime: Context memory, values, strings, arrays,
traps, coroutine state, deterministic numeric formatting
Runtime faults (out-of-bounds, failed narrowing, division by zero) are
traps: the Context stops with a diagnostic carrying a source position
and hands control back to the host — no signals, no unwinding across the C
boundary. Coroutines (function*) are a CPS transform with suspended
state living in the runtime, so they are safe on platforms without stack
switching.
The language is defined by an executable corpus, not by prose:
corpus/accept/— programs the language must accept and run, each with a committed golden output.corpus/reject/— programs the compiler must refuse, each with the rule it must cite.
A syntax or semantics decision without a corpus entry is not decided. A sound language is defined as much by what it rejects as by what it accepts.
Rust toolchain, plus a C compiler (cc/clang) for the shipping tier and
the layout proof.
cargo test # checker, runtime, both tiers, the differential gateEditor-tooling / soundness gate (requires Node + TypeScript):
npm install
npx tsc -p tsconfig.json # every accept program type-checks under stock tscDevice-triple compile+link check (needs Xcode and/or the Android NDK):
sh codegen/device-link.shThe developer command (specs/blocks/cli.md)
owns the emit → compile → link pipeline the examples use. Build it once:
cargo build --release -p subscript-cli
alias subscript=target/release/subscript # or put it on PATHRun a program under the development tier (JIT), or type-check it and produce nothing:
subscript run examples/e01-sized-integers.ts
subscript check game.ts --mirror engine.generated.d.tsFor a host with its own build system, emit the C translation unit and ask what the link line must add:
subscript bind --header engine.h -o engine.generated.d.ts
subscript emit game.ts --mirror engine.generated.d.ts --no-entry -o out/
subscript link-flags # runtime include dir, static archive, system libsOr build and run a complete host in one step — this is all
examples/host/build.sh does:
subscript build \
--source examples/host/game.ts \
--mirror examples/engine/engine.generated.d.ts \
--host examples/engine/engine.c \
--host examples/host/main.c \
-o target/examples-host --runInside this repository the CLI builds and finds the runtime archive
itself. Outside it, point the CLI at an installed runtime with
--runtime-lib / --runtime-include or the SUBSCRIPT_RUNTIME_LIB /
SUBSCRIPT_RUNTIME_INCLUDE environment variables.
The core language is implemented: the semantic checker and typed HIR, the
runtime, both execution tiers, the standing dev≡ship differential gate
over the corpus, a performance gate, the C-header binding slice
(mirror generator, layout proof, and the five interop patterns as corpus
entries), and the subscript developer CLI. It is a young language under active development; the surface
grows as the corpus grows.
Design and phase records live in specs/: specs/blocks/ holds
the area contracts (corpus, collisions, compiler) and
specs/subscript-project-plan.md the overall plan.
Dual-licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual-licensed as above, without any additional terms or conditions.