Skip to content

TypeScript Extension

The TypeScript extension provides two things:

  • Runtime support for fn typescript ... blocks (V8 via deno_core).
  • Child-LSP configuration so Shape LSP can delegate hover/completion/diagnostics inside TypeScript bodies.

The extension transpiles TypeScript to JavaScript and executes it in an embedded V8 isolate (the same engine that powers Node.js and Deno). Each fn typescript block becomes a wrapper function registered in V8 and invoked once per call.

Terminal window
shape ext install typescript

This downloads, compiles, and installs the extension to ~/.shape/extensions/. Both the CLI and LSP will automatically detect it — no further configuration needed.

If you need a development build from the workspace:

Terminal window
cargo build -p shape-ext-typescript --release

This produces target/release/libshape_ext_typescript.so (Linux).

If you installed via shape ext install, the extension is loaded automatically. For manual loading, use any of the following.

Terminal window
shape run script.shape --extension typescript
---
[[extensions]]
name = "typescript"
---
[[extensions]]
name = "typescript"
autoload = true

Extensions installed via shape ext install are resolved by name from ~/.shape/extensions/. You can also specify an explicit path for development builds.

Declare a TypeScript function with fn typescript:

fn typescript add(a: int, b: int) -> Result<int> {
return a + b
}
let result = add(1, 2)
// result: Ok(3)

Arguments are marshalled from Shape to V8, the TypeScript body is transpiled to JavaScript and executed, and the return value is marshalled back. Both directions are type-checked against the declared signature.

Every fn typescript block must have an explicit return type, and that return type must be Result<T> — TypeScript is a dynamic-error-model runtime, so the compiler refuses bare return types to guarantee every call is forced to handle the failure case.

// Dynamic errors — JS/TS exceptions become Err(...)
fn typescript safe_divide(a: number, b: number) -> Result<number> {
if (b === 0) throw new Error("division by zero");
return a / b
}

(Earlier drafts of this chapter showed a “static error model” form without Result<T>. The compiler now rejects that form with Foreign function ...: return type must be Result<T> ....)

The runtime advertises error_model: Dynamic in its LanguageRuntimeVTable, which is why Result<T> is automatically wired up for thrown exceptions.

Values crossing the TypeScript/Shape boundary are converted via MessagePack:

Shape typeTypeScript typeDirectionNotes
intnumberbothInteger values fitting in i32 are passed as V8 Integer; larger values via Number (f64)
number / floatnumberbothIEEE-754 double precision
stringstringbothUTF-8
boolbooleanboth
nonenullbothnull and undefined both marshal to none
Vec<T>Array<T>bothEach element validated recursively against T
{f: T, ...}objectbothDeclared fields validated; produces a TypedObject on return

Coercion rules:

  • TypeScript numbers that are exact integers and fit in i64 marshal back to int; otherwise they marshal back to number.
  • boolean and number are distinct types — no implicit coercion in either direction.
  • Type mismatches surface as Err(MARSHAL_ERROR) in the dynamic error model.

When a foreign function declares an inline object return type, Shape creates a schema at compile time and validates the JavaScript object on return:

fn typescript fetch_user(id: int) -> Result<{name: string, age: int}> {
return { name: "Alice", age: 30 }
}
let user = fetch_user(1).unwrap()
print(user) // {name: "Alice", age: 30}
print(user.name) // "Alice"
print(user.age) // 30

The returned object must contain all declared fields with the correct types. Missing fields or type mismatches produce a MARSHAL_ERROR.

With Result<T> return types, two error codes distinguish failure sources:

Error codeMeaning
RUNTIME_ERRORTypeScript/JS threw an exception (throw new Error(...), runtime type error, etc.)
MARSHAL_ERRORReturn value didn’t match declared type T, or argument deserialization failed
fn typescript bad_return(x: int) -> Result<int> {
return "not an int"
}
let r = bad_return(1)
// r: Err({code: "MARSHAL_ERROR", ...})

Thrown exceptions retain the V8 .stack trace when available; the extension attempts .stack first, falls back to .message, and last-resort stringifies the thrown value.

Add the async keyword for I/O-bound TypeScript:

async fn typescript fetch_json(url: string) -> Result<{value: number}> {
const response = await fetch(url);
return await response.json();
}

async fn typescript wraps the body in an async function, returning a Promise. The extension uses a cached Tokio runtime (built once per TsRuntime instance on the first async call) to drive deno_core’s event loop until the promise resolves. The Shape VM blocks the calling task until the promise settles.

Shape LSP can discover extension declarations from script frontmatter and shape.toml.

If you want TypeScript always available in the editor (even before the current file declares it), configure always-load extensions in LSP initialization/settings — the keys are the same as for the Python extension (see Python Extension :: Supported LSP Config Keys).

The TypeScript extension advertises typescript-language-server --stdio for child LSP startup. Make sure typescript-language-server is on your PATH if you want completions and diagnostics inside TypeScript bodies.

After installing the extension (shape ext install typescript), the LSP auto-detects it from ~/.shape/extensions/. No manual path configuration is needed:

return {
{
"neovim/nvim-lspconfig",
opts = function()
vim.filetype.add({ extension = { shape = "shape" } })
vim.lsp.config.shape = {
cmd = { "shape-lsp" },
filetypes = { "shape" },
root_markers = { ".git", "shape.toml" },
}
vim.lsp.enable("shape")
end,
},
}

The TypeScript extension bundles a small .shape source that registers the typescript namespace (not std::core::typescript) when the extension loads. This is what makes the following importable:

import { eval, import as ts_import } from typescript
let answer: int = eval("40 + 2")
let mod = ts_import("./helpers.ts")
  • eval(code: string) -> _ — evaluates a TypeScript/JavaScript expression in the extension’s V8 isolate and marshals the result back to a Shape value.
  • import(specifier: string) -> _ — resolves and loads a module in the V8 runtime; the returned handle provides access to the module’s exports.

This bundling mechanism is the get_shape_source vtable field documented in Polyglot Functions :: Capability Contract.

No language runtime registered for 'typescript'

Section titled “No language runtime registered for 'typescript'”

The extension was not loaded in runtime context. Verify frontmatter / shape.toml / --extension path and rebuild if needed.

async fn typescript blocks until the returned promise settles. If a promise never resolves (for example, awaiting a fetch with no timeout against an unreachable host), the Shape VM will hang on the awaiting call. Use Result<T> and surface a timeout from inside the TypeScript body for defensive code paths.

Hover works but TypeScript semantic highlighting is missing

Section titled “Hover works but TypeScript semantic highlighting is missing”

Shape delegates highlighting to the child TypeScript LSP. If typescript-language-server is missing or does not implement semantic-token methods, hover can work while semantic highlighting is unavailable.

Verify child server availability:

Terminal window
typescript-language-server --stdio

The current TypeScript extension advertises this command for child LSP startup.