Skip to content
infosiaPublic

About

Embeddable statically-typed TypeScript-subset for native apps. C-ABI data layout, zero-copy C interop, no implicit GC. JIT hot reload in dev, ships as AOT C.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

subscript

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.

Why it exists

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.

Who it's for

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.ts prelude. Note that tsserver checks 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), and Context.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.

Who it's not for

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, no eval, no implicit f64 number. 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.

Tutorials

  • 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, null without undefined, explicit memory, Error-family exceptions beside uncatchable traps, host-stepped async, 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.

What you get

Sound TypeScript-subset syntax

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.

C-ABI-identical data layout

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.

Zero-copy C interop

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.

No implicit GC

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.

A deterministic standard library

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.

Two execution tiers, and a third witness

  • 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 cl on 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.

Performance

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/reduce over 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×).
  • collect is 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 on callbacks, and behind JSC on collect. JSC/V8 lead on tree, 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.

Iteration speed

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.

How it works

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 corpus is the definition

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.

Building and testing

Rust toolchain, plus a C compiler (cc/clang) for the shipping tier and the layout proof.

cargo test            # checker, runtime, both tiers, the differential gate

Editor-tooling / soundness gate (requires Node + TypeScript):

npm install
npx tsc -p tsconfig.json   # every accept program type-checks under stock tsc

Device-triple compile+link check (needs Xcode and/or the Android NDK):

sh codegen/device-link.sh

Using the subscript CLI

The 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 PATH

Run 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.ts

For 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 libs

Or 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 --run

Inside 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.

Status

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.

License

Dual-licensed under either of

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.

About

Embeddable statically-typed TypeScript-subset for native apps. C-ABI data layout, zero-copy C interop, no implicit GC. JIT hot reload in dev, ships as AOT C.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Contributors

Languages