tsrun 0.1.8

A TypeScript interpreter designed for embedding in applications
Documentation

tsrun

A minimal TypeScript runtime in Rust for embedding in applications.

Overview

tsrun is designed for configuration files where you want the full benefits of TypeScript in your editor: autocompletion, type checking, and error highlighting. The runtime executes TypeScript directly without transpilation, using a register-based bytecode VM.

Why TypeScript for configs?

  • IDE autocompletion for your config schema
  • Catch errors before runtime with type checking in your editor
  • Native support for enums, interfaces, and type annotations
  • No Node.js dependency - embed directly in your application

Features

TypeScript Support

  • Enums - Native support with numeric and string enums, including reverse mappings
  • Type Annotations - Full parsing of types, interfaces, type aliases, and generics
  • Decorators - Class, method, property, and parameter decorators
  • Namespaces - TypeScript namespace declarations
  • Parameter Properties - constructor(public x: number) syntax support
  • Type Assertions - Both x as T and <T>x syntaxes

JavaScript Features

  • ES Modules - Full import/export support with step-based module loading
  • Async/Await - Promises, async functions, Promise.all/race/allSettled
  • Classes - Inheritance, static blocks, private fields, getters/setters
  • Generators - function*, yield, yield*, for...of iteration
  • Destructuring - Arrays, objects, function parameters, rest/spread
  • eval() - Dynamic code evaluation
  • Built-ins - Array, String, Object, Map, Set, Date, RegExp, JSON, Math, Proxy, Reflect, Symbol

Embedding

  • Minimal Runtime - Small footprint, no Node.js dependency
  • Rust & C APIs - Full integration support for host applications
  • WASM Support - Run in browsers via WebAssembly
  • no_std Compatible - Can run in environments without the standard library

Installation

CLI

cargo install tsrun

Library (Rust)

[dependencies]
tsrun = "0.1"

C/C++ Embedding

cargo build --release --features c-api
# Produces: target/release/libtsrun.so (Linux), .dylib (macOS), .dll (Windows)

Quick Start

CLI

# Run a TypeScript file
tsrun script.ts

# With ES modules
tsrun main.ts  # automatically resolves imports

Rust Library

use tsrun::{Interpreter, StepResult};

fn main() -> Result<(), tsrun::JsError> {
    let mut interp = Interpreter::new();

    // Prepare code for execution
    interp.prepare("1 + 2 * 3", None)?;

    // Step until completion
    loop {
        match interp.step()? {
            StepResult::Continue => continue,
            StepResult::Complete(value) => {
                println!("Result: {}", value.as_number().unwrap()); // 7.0
                break;
            }
            _ => break,
        }
    }
    Ok(())
}

C Embedding

#include "tsrun.h"

int main() {
    TsRunContext* ctx = tsrun_new();

    tsrun_prepare(ctx, "1 + 2 * 3", NULL);
    TsRunStepResult result = tsrun_run(ctx);

    if (result.status == TSRUN_STEP_COMPLETE) {
        printf("Result: %g\n", tsrun_get_number(result.value));
        tsrun_value_free(result.value);
    }

    tsrun_step_result_free(&result);
    tsrun_free(ctx);
    return 0;
}

Rust API

Basic Execution

use tsrun::{Interpreter, StepResult, JsValue};

let mut interp = Interpreter::new();
interp.prepare(r#"
    const greeting = "Hello";
    const target = "World";
    `${greeting}, ${target}!`
"#, None)?;

loop {
    match interp.step()? {
        StepResult::Continue => continue,
        StepResult::Complete(value) => {
            assert_eq!(value.as_str(), Some("Hello, World!"));
            break;
        }
        _ => break,
    }
}

ES Module Loading

The interpreter uses step-based execution that pauses when imports are needed:

use tsrun::{Interpreter, StepResult, ModulePath};

let mut interp = Interpreter::new();

// Main module with imports
interp.prepare(r#"
    import { add } from "./math.ts";
    export const result = add(2, 3);
"#, Some(ModulePath::new("/main.ts")))?;

loop {
    match interp.step()? {
        StepResult::Continue => continue,
        StepResult::NeedImports(imports) => {
            for import in imports {
                // Load module source from filesystem, network, etc.
                let source = match import.resolved_path.as_str() {
                    "/math.ts" => "export function add(a: number, b: number) { return a + b; }",
                    _ => panic!("Unknown module"),
                };
                interp.provide_module(import.resolved_path, source)?;
            }
        }
        StepResult::Complete(value) => {
            println!("Done: {}", value);
            break;
        }
        _ => break,
    }
}

Working with Values

use tsrun::{Interpreter, api, JsValue};
use serde_json::json;

let mut interp = Interpreter::new();
let guard = api::create_guard(&interp);

// Create values from JSON
let user = api::create_from_json(&mut interp, &guard, &json!({
    "name": "Alice",
    "age": 30,
    "tags": ["admin", "developer"]
}))?;

// Read properties
let name = api::get_property(&user, "name")?;
assert_eq!(name.as_str(), Some("Alice"));

// Modify properties
api::set_property(&user, "email", JsValue::from("alice@example.com"))?;

// Call methods
let tags = api::get_property(&user, "tags")?;
let joined = api::call_method(&mut interp, &guard, &tags, "join", &[JsValue::from(", ")])?;
assert_eq!(joined.as_str(), Some("admin, developer"));

Async/Await with Orders

For async operations, the interpreter pauses with pending "orders" that the host fulfills:

use tsrun::{Interpreter, StepResult, OrderResponse, RuntimeValue, api};

let mut interp = Interpreter::new();

// Code that uses the order system for async I/O
interp.prepare(r#"
    import { order } from "tsrun:host";
    const response = await order({ url: "/api/users" });
    response.data
"#, Some("/main.ts".into()))?;

loop {
    match interp.step()? {
        StepResult::Continue => continue,
        StepResult::Suspended { pending, cancelled } => {
            let mut responses = Vec::new();
            for order in pending {
                // Examine order.payload to determine what to do
                // Create response value
                let response = api::create_response_object(&mut interp, &serde_json::json!({
                    "data": [{"id": 1, "name": "Alice"}]
                }))?;
                responses.push(OrderResponse {
                    id: order.id,
                    result: Ok(response),
                });
            }
            interp.fulfill_orders(responses);
        }
        StepResult::Complete(value) => {
            println!("Result: {}", value);
            break;
        }
        _ => break,
    }
}

Accessing Module Exports

use tsrun::{Interpreter, StepResult, api};

let mut interp = Interpreter::new();
interp.prepare(r#"
    export const VERSION = "1.0.0";
    export const CONFIG = { debug: true };
"#, Some("/config.ts".into()))?;

// Run to completion
loop {
    match interp.step()? {
        StepResult::Continue => continue,
        StepResult::Complete(_) => break,
        _ => break,
    }
}

// Access exports
let version = api::get_export(&interp, "VERSION");
assert_eq!(version.unwrap().as_str(), Some("1.0.0"));

let export_names = api::get_export_names(&interp);
assert!(export_names.contains(&"VERSION".to_string()));
assert!(export_names.contains(&"CONFIG".to_string()));

C API

See examples/c-embedding/ for complete examples.

WASM / Browser

The interpreter compiles to WebAssembly for browser execution. Try it at the online playground.

Building

cd examples/wasm-playground
./build.sh              # Build WASM module
./build.sh --test       # Build and run tests

JavaScript API

import init, { TsRunner, STEP_CONTINUE, STEP_COMPLETE, STEP_ERROR } from './pkg/tsrun.js';

await init();
const runner = new TsRunner();

// Status constants (functions that return values)
const Status = {
    CONTINUE: STEP_CONTINUE(),
    COMPLETE: STEP_COMPLETE(),
    ERROR: STEP_ERROR()
};

// Prepare and run
runner.prepare('console.log("Hello!"); 1 + 2', 'script.ts');

while (true) {
    const result = runner.step();

    // Display console output
    for (const entry of result.console_output) {
        console.log(`[${entry.level}] ${entry.message}`);
    }

    if (result.status === Status.COMPLETE) {
        console.log('Result:', result.value);
        break;
    } else if (result.status === Status.ERROR) {
        console.error('Error:', result.error);
        break;
    }
}

Async Operations

TypeScript code can use order for async operations that the JavaScript host fulfills:

import { order } from "tsrun:host";

function fetch(url: string): Promise<any> {
    return order({ type: "fetch", url });
}

const [user, posts] = await Promise.all([
    fetch("/api/users/1"),
    fetch("/api/posts")
]);
// In the step loop, handle STEP_SUSPENDED status:
if (result.status === STEP_SUSPENDED()) {
    const orders = runner.get_pending_orders();
    // orders = [{ id: 1, payload: { type: "fetch", url: "/api/users/1" } }, ...]

    const responses = await Promise.all(orders.map(async order => {
        const data = await realFetch(order.payload.url);
        return { id: order.id, result: data };
    }));

    runner.fulfill_orders(responses);
}

Native Functions

Register C functions callable from JavaScript:

static TsRunValue* native_add(TsRunContext* ctx, TsRunValue* this_arg,
                              TsRunValue** args, size_t argc,
                              void* userdata, const char** error_out) {
    double a = tsrun_get_number(args[0]);
    double b = tsrun_get_number(args[1]);
    return tsrun_number(ctx, a + b);
}

// Register
TsRunValueResult fn = tsrun_native_function(ctx, "add", native_add, 2, NULL);
tsrun_set_global(ctx, "add", fn.value);

// Use from JS: add(10, 20) -> 30

Module Loading

TsRunStepResult result = tsrun_run(ctx);

while (result.status == TSRUN_STEP_NEED_IMPORTS) {
    for (size_t i = 0; i < result.import_count; i++) {
        const char* path = result.imports[i].resolved_path;
        const char* source = load_from_filesystem(path);
        tsrun_provide_module(ctx, path, source);
    }
    tsrun_step_result_free(&result);
    result = tsrun_run(ctx);
}

Feature Flags

Flag Description Default
std Full standard library support Yes
regex Regular expression support (requires std) Yes
console Console.log builtin Yes
c-api C FFI for embedding (requires std) No
# Minimal build without regex
[dependencies]
tsrun = { version = "0.1", default-features = false, features = ["std"] }

# With C API
[dependencies]
tsrun = { version = "0.1", features = ["c-api"] }

TypeScript Examples

Enums

enum LogLevel {
    Debug = 0,
    Info = 1,
    Warn = 2,
    Error = 3
}

enum Status {
    Pending = "pending",
    Active = "active",
    Completed = "completed"
}

interface Config {
    logLevel: LogLevel;
    status: Status;
    retries: number;
}

const config: Config = {
    logLevel: LogLevel.Info,
    status: Status.Active,
    retries: 3
};

// Reverse mapping works for numeric enums
LogLevel[1]; // "Info"

Classes with Inheritance

class Entity {
    static #count = 0;
    #id: number;

    constructor() {
        this.#id = ++Entity.#count;
    }

    get id() { return this.#id; }
    static getCount() { return Entity.#count; }
}

class User extends Entity {
    constructor(public name: string, public email: string) {
        super();
    }

    toJSON() {
        return { id: this.id, name: this.name, email: this.email };
    }
}

const user = new User("Alice", "alice@example.com");
JSON.stringify(user.toJSON());

Generators

function* fibonacci(): Generator<number> {
    let [a, b] = [0, 1];
    while (true) {
        yield a;
        [a, b] = [b, a + b];
    }
}

function* take<T>(gen: Generator<T>, n: number): Generator<T> {
    for (const value of gen) {
        if (n-- <= 0) return;
        yield value;
    }
}

const first10 = [...take(fibonacci(), 10)];
// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Async/Await

async function fetchUserWithPosts(userId: number) {
    const [user, posts] = await Promise.all([
        fetchUser(userId),
        fetchUserPosts(userId)
    ]);
    return { user, posts };
}

async function processAll<T, R>(
    items: T[],
    fn: (item: T) => Promise<R>
): Promise<R[]> {
    const results: R[] = [];
    for (const item of items) {
        results.push(await fn(item));
    }
    return results;
}

Config Generation

import { DEFAULT_CONFIG } from "./defaults";

const envOverrides = {
    production: {
        database: { host: "prod-db.example.com", ssl: true },
        logging: { level: "warn" }
    },
    development: {
        database: { host: "localhost" },
        logging: { level: "debug" }
    }
};

function buildConfig(env: string) {
    return {
        ...DEFAULT_CONFIG,
        ...(envOverrides[env] || {})
    };
}

JSON.stringify(buildConfig("production"), null, 2);

Testing

# Run all tests
cargo test

# Run specific test
cargo test test_array_map

# Run with output
cargo test -- --nocapture

Test262 Conformance

git submodule update --init --depth 1
cargo build --release --bin test262-runner
./target/release/test262-runner --strict-only language/types

Performance

The interpreter uses a register-based bytecode VM:

  • Fewer instructions than stack-based VMs
  • Better cache locality
  • Efficient state capture for async/generators

Release builds use LTO and are optimized for size (opt-level = "z").

Limitations

  • No runtime type checking - Types are parsed and stripped for IDE support, not validated at runtime
  • Strict mode only - All code runs in strict mode
  • Single-threaded - One interpreter instance per thread

License

MIT