Recursive AI

Run TypeScript in C#

OneEcma is a free, open-source JavaScript interpreter for .NET from Recursive AI. MIT or Apache-2.0, no native dependencies.

TypeScript is JavaScript with types added. OneEcma erases the types and runs the JavaScript that’s left, inside your .NET app.

Nothing is type-checked, and enum, namespaces with values and parameter properties are refused.

Terminal
dotnet add package RecursiveAI.OneEcma

Requires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.

C#
using RecursiveAI.OneEcma;
 
var ts = File.ReadAllText("orders.ts");
var js = ScriptEngine.StripTypes(ts).Code;
JsValue v = new ScriptEngine().Evaluate(js!);
Console.WriteLine(v.AsString);

Download this example (.zip): unzip, then dotnet run.

Prints the value of the last line of orders.ts (shown below, and in the download): #1: 4.50 and #2: 7.65.

If the file uses TypeScript that can’t simply be erased, such as an enum, Code is null and Diagnostics holds one report per construct with the plain-JavaScript fix.

The full program

This is the Program.cs in the download. It runs three small TypeScript files that come with it. The first, orders.ts, uses an interface, a type alias, annotations and an optional property:

TYPESCRIPT
interface LineItem { sku: string; qty: number; price: number }
type Order = { id: number; items: LineItem[]; coupon?: string };
 
function total(order: Order): number {
  const sum = order.items.reduce((t: number, i: LineItem) => t + i.qty * i.price, 0);
  return order.coupon === "TENOFF" ? sum * 0.9 : sum;
}
 
const orders: Order[] = [
  { id: 1, items: [{ sku: "pen", qty: 3, price: 1.5 }] },
  { id: 2, items: [{ sku: "ink", qty: 2, price: 4.25 }], coupon: "TENOFF" },
];
 
orders.map((o: Order) => `#${o.id}: ${total(o).toFixed(2)}`).join("\n");

shipping.ts asserts with ! that every customer has an address, and the second one doesn’t:

TYPESCRIPT
type Address = { city: string; zip?: string };
type Customer = { name: string; address?: Address };
 
function label(c: Customer): string {
  const city: string = c.address!.city;
  return `${c.name}, ${city.toUpperCase()}`;
}
 
const customers: Customer[] = [
  { name: "Ada", address: { city: "London" } },
  { name: "Grace" },
];
 
customers.map(label).join("\n");

status.ts uses an enum and constructor parameter properties, which can’t be erased:

TYPESCRIPT
enum Status { Open, Shipped }
 
class Shipment {
  constructor(private id: number, public status: Status) {}
}
 
new Shipment(7, Status.Open);
C#
using RecursiveAI.OneEcma;
using RecursiveAI.OneEcma.Diagnostics;
using RecursiveAI.OneEcma.Syntax;
 
// Strip the types from each TypeScript file, then run the JavaScript that's left.
string[] files = args.Length > 0 ? args : ["orders.ts", "shipping.ts", "status.ts"];
 
foreach (string file in files)
{
    Console.WriteLine($"--- {file}");
    string ts = File.ReadAllText(file);
 
    TypeStripResult stripped = ScriptEngine.StripTypes(ts,
        new TypeStripOptions { FileName = file, Module = false });
 
    if (!stripped.Success)
    {
        // Not erasable (an enum, a parameter property...): one report per construct.
        foreach (Diagnostic d in stripped.Diagnostics)
            Console.WriteLine(DiagnosticRenderer.Render(d));
        continue;
    }
 
    try
    {
        JsValue result = new ScriptEngine().Evaluate(stripped.Code!, file);
        Console.WriteLine(result.AsString);
    }
    catch (JsException e)
    {
        // Positions are kept, so the report points at the line in the .ts file.
        Console.WriteLine(DiagnosticRenderer.Render(e.Report!));
    }
}

dotnet run prints:

Text
--- orders.ts
#1: 4.50
#2: 7.65
--- shipping.ts
error[TypeError]: Cannot read properties of undefined (reading 'city')
 --> shipping.ts:5:26
  |
5 |   const city         = c.address .city;
  |                          ^^^^^^^ `c.address` is undefined
  |
  = help: guard it: `c.address?.city`
  = stack: label (shipping.ts:5) ← <top-level> (shipping.ts:14)
 
--- status.ts
error[SyntaxError]: TypeScript 'enum' is not erasable syntax
 --> status.ts:1:1
  |
1 | enum Status { Open, Shipped }
  | ^^^^^^^^^^^ an enum compiles to code that builds an object, which type erasure cannot produce
  |
  = help: use a `const` object instead: `const Color = { Red: 0, Green: 1 } as const;` and, if the type is needed, `type Color = (typeof Color)[keyof typeof Color];`
  = note: StripTypes only erases types; it never generates code, so this construct cannot be run as written
 
error[SyntaxError]: TypeScript parameter properties are not erasable syntax
 --> status.ts:4:15
  |
4 |   constructor(private id: number, public status: Status) {}
  |               ^^^^^^^^^^ this modifier makes the parameter also a class field, which compiles to an assignment
  |
  = help: declare the field in the class body and assign it in the constructor: `x; constructor(x) { this.x = x; }`
  = note: StripTypes only erases types; it never generates code, so this construct cannot be run as written
 
error[SyntaxError]: TypeScript parameter properties are not erasable syntax
 --> status.ts:4:35
  |
4 |   constructor(private id: number, public status: Status) {}
  |                                   ^^^^^^^^^^^^^ this modifier makes the parameter also a class field, which compiles to an assignment
  |
  = help: declare the field in the class body and assign it in the constructor: `x; constructor(x) { this.x = x; }`
  = note: StripTypes only erases types; it never generates code, so this construct cannot be run as written

orders.ts runs. Its types are gone and the rest runs as JavaScript. Evaluate returns the value of the last expression. Module = false tells the stripper that this file is a classic script, which is how Evaluate runs it. Leave it at its default, true, for files with import and export (see the variations).

shipping.ts fails while it runs. It reads c.address!.city, and the second customer has no address. The ! told TypeScript the address is there; stripping erases it and checks nothing. The report says shipping.ts:5:26, which is line 5, column 26 of the .ts file: erased text becomes spaces, so every position in the JavaScript is the same as in the TypeScript, with no source map. The quoted line is the stripped text, so the types show as blanks.

status.ts is refused. An enum and constructor parameter properties need code generated for them, which stripping never does. Success is false, nothing runs, and there is one report for each construct in the file, in source order. To run your own files, name them: dotnet run -- a.ts b.ts.

Variations

See what stripping does. Every type becomes spaces of the same width:

C#
using RecursiveAI.OneEcma;
 
string ts = "const price: number = (input as any).price!;";
Console.WriteLine(ts);
Console.WriteLine(ScriptEngine.StripTypes(ts).Code);
Text
const price: number = (input as any).price!;
const price         = (input       ).price ;

Write a .js file instead of running it. It has as many lines as the .ts file:

C#
using RecursiveAI.OneEcma;
using RecursiveAI.OneEcma.Syntax;
 
TypeStripResult r = ScriptEngine.StripTypes(File.ReadAllText("orders.ts"));
if (r.Success) File.WriteAllText("orders.js", r.Code);
Console.WriteLine(File.ReadAllLines("orders.js").Length);
Text
14

Call a TypeScript function from C#. Run the stripped code once, then call the function it defined:

C#
using RecursiveAI.OneEcma;
 
var engine = new ScriptEngine();
engine.Evaluate(ScriptEngine.StripTypes("""
    function vat(net: number, rate: number = 0.2): number {
      return Math.round(net * (1 + rate) * 100) / 100;
    }
    """).Code!, "vat.ts");
 
JsValue vat = engine.GetProperty(engine.Global, "vat");
JsValue gross = engine.Call(vat, default, [JsValue.FromNumber(19.99)]);
Console.WriteLine(gross.AsNumber);
Text
23.99

TypeScript modules with import and export. Strip each file and serve the results from a module host. The import type and inline type specifiers are erased:

C#
using RecursiveAI.OneEcma;
 
var sources = new Dictionary<string, string>
{
    ["money.ts"] = "export type Cents = number;\n" +
                   "export const fmt = (c: Cents): string => `$${(c / 100).toFixed(2)}`;",
    ["main.ts"] = "import { fmt, type Cents } from './money.ts';\n" +
                  "const total: Cents = 1999;\n" +
                  "export const label = fmt(total);",
};
 
var host = new InMemoryModuleHost();
foreach (var (name, ts) in sources)
    host.Add(name, ScriptEngine.StripTypes(ts, new() { FileName = name }).Code!);
 
var engine = new ScriptEngine(new ScriptEngineOptions { Host = host });
JsValue ns = engine.EvaluateModule("main.ts");
Console.WriteLine(engine.GetProperty(ns.AsObject!, "label").AsString);
Text
$19.99

A syntax error is reported as a SyntaxError with its place in the TypeScript:

C#
using RecursiveAI.OneEcma;
using RecursiveAI.OneEcma.Diagnostics;
 
var r = ScriptEngine.StripTypes("let a: number = 1;\nlet b: string = ;",
    new() { FileName = "typo.ts" });
Console.WriteLine(r.Success);
Console.WriteLine(DiagnosticRenderer.Render(r.Diagnostics[0]));
Text
False
error[SyntaxError]: Unexpected token ';'
 --> typo.ts:2:17
  |
2 | let b: string = ;
  |                 ^

err.stack inside the script has the .ts file’s lines and columns too:

C#
using RecursiveAI.OneEcma;
 
string ts = """
    function parse(raw: string): { qty: number } {
      if (!raw) throw new Error("empty input");
      return JSON.parse(raw) as { qty: number };
    }
    try { parse(""); } catch (e: unknown) { (e as Error).stack; }
    """;
var engine = new ScriptEngine();
Console.WriteLine(engine.Evaluate(ScriptEngine.StripTypes(ts).Code!, "parse.ts").AsString);
Text
Error: empty input
    at parse (parse.ts:2:19)
    at parse.ts:5:7

TypeScript you didn’t write, from a user or a model, runs under limits. Here an endless loop is stopped:

C#
using RecursiveAI.OneEcma;
using RecursiveAI.OneEcma.Diagnostics;
 
string ts = "let n: number = 0;\nwhile (true) { n++; }";
var engine = new ScriptEngine(new ScriptEngineOptions
{
    Limits = new EngineLimits { Fuel = 1_000_000, WallClockMs = 1_000 },
});
try
{
    engine.Evaluate(ScriptEngine.StripTypes(ts).Code!, "loop.ts");
}
catch (JsLimitException e)
{
    Console.WriteLine(DiagnosticRenderer.Render(e.Report!));
}
Text
error[Limit]: Execution exceeded the step limit of 1000000 steps
 --> loop.ts:2:1
  |
2 | while (true) { n++; }
  | ^^^^^^^^^^^^^^^^^^^^^
  |
  = stack: <top-level> (loop.ts:2)

The short answer

To run TypeScript in C#, add the RecursiveAI.OneEcma package, pass the source to ScriptEngine.StripTypes, and run the Code it returns with ScriptEngine.Evaluate. StripTypes turns TypeScript that uses only erasable syntax into JavaScript by replacing the types with spaces, so a line and column in an error report are the same as in your .ts file. It doesn’t type-check: the docs say “Nothing is type-checked; types are skipped, never resolved.” TypeScript that needs generated code, such as an enum, is refused with a report that names the construct and shows the plain-JavaScript form.

OneEcma is free and open source under MIT or Apache 2.0, for commercial use too. The 1.1.1 package is a single managed assembly for .NET 10, with no native binaries and no dependencies beyond the .NET base class library. StripTypes arrived in 1.1.0. The rest of this page answers the questions people ask next.

How do you convert TypeScript to JavaScript?

There are two ways, and they give different JavaScript.

  • Compile it. The TypeScript compiler (tsc) checks the types, then writes JavaScript. It generates code for the TypeScript features that have no JavaScript equivalent, such as enums. The output has a different layout from your source, so tools need a source map to point back at the .ts file.
  • Strip it. Remove the types and keep everything else as it is. Nothing is checked and nothing is generated, so only TypeScript whose types can be deleted without changing what the code does can be stripped.

In C#, ScriptEngine.StripTypes does the second. It takes a string and returns a TypeStripResult whose Code is the JavaScript, or null on failure, with the reasons in Diagnostics. TypeStripOptions.FileName names the file in reports, and Module (default true) picks whether the result is checked as a module or a classic script. You can run the result in the same process, as above, or save it as a .js file for anything else that runs JavaScript.

Choose stripping when the code is yours or follows a known style, and you want to run it without a build step: plugins, rules, scripts users write in an editor with TypeScript checking, or code a model wrote. Choose the compiler when the code uses enums, namespaces or parameter properties and you can’t change it, or when you need its type checking. OneEcma runs the compiler’s output too, and reads its source maps (see source maps).

What is TypeScript type stripping?

Type stripping deletes the parts of a TypeScript file that only describe types and runs what’s left. OneEcma’s version keeps every position. In the words of its TypeScript docs:

Erased text becomes spaces. Every line terminator and every other character stays where it was, so a line and column in the result is the same line and column in the .ts file, and runtime reports, err.stack and syntax errors point at the original with no source map. Source with nothing to erase is returned as the same string.

That’s why the shipping.ts report above says line 5, column 26, the place in the TypeScript where c.address is read.

The docs list what is erased:

  • type annotations on variables, parameters (including ?), return types, class fields, catch bindings and this parameters;
  • type parameters and arguments on declarations and calls, and generic arrow functions;
  • as T, as const, satisfies T and the non-null !;
  • interface and type declarations, declare declarations, overload signatures, abstract methods and fields, index signatures, implements clauses;
  • namespaces and modules whose body holds only types;
  • import type and export type statements, and inline type specifiers;
  • the modifiers public, private, protected, readonly, override, abstract and declare on class members, and the optional (?) and definite (!:) markers on fields.

The docs explain why the stripper sometimes writes into the blanks: “Blanking alone would change meaning in a few places, so one token is written into erased text.” The guide it follows is JavaScript’s automatic semicolon insertion. When an erased declaration (type, interface, declare, import type and similar) sits between a line that could go on and a line starting with (, [, a template or an operator, the stripper writes a ; over the first column of the declaration, so the two statements stay apart. Also, “A ; in the source is never erased.”

C#
using RecursiveAI.OneEcma;
 
string ts = "let x = f\ntype T = number\n(g)()";
Console.WriteLine(ScriptEngine.StripTypes(ts).Code);
Text
let x = f
;
(g)()

Without that ;, the result would run as f(g)(). The stripper does the same after as T or satisfies T followed on a later line by (, [ or a template, and after a type-argument list that ends a line before a [. When an arrow function’s return type spans a line break, it moves the => up next to the ). If the colon ends its line, there’s no room for that, and the file is refused: put the first line of the type after the colon.

Which TypeScript syntax can’t be stripped?

Anything that turns into JavaScript code of its own. The TypeScript docs for erasableSyntaxOnly list the same constructs: “enum declarations”, “namespaces and modules with runtime code”, “parameter properties in classes”, “Non-ECMAScript import = and export = assignments” and “<prefix>-style type assertions”. OneEcma refuses each one with its own code:

CodeConstructWrite instead
ts-enumenum, const enuma const object with as const
ts-namespacenamespace or module that declares valuesa module or a plain object
ts-parameter-propertyconstructor(private x)declare the field, assign it in the constructor
ts-import-equalsimport x = require(), import A = B.Ca standard import, or a const
ts-export-assignmentexport = xexport default or named exports
ts-angle-cast<T>exprexpr as T
ts-parameter-decoratorm(@dec x)pass the value explicitly
ts-arrow-return-type-layoutan arrow return type that starts on the line after :start the type on the colon’s line

Two more cases. Class and member decorators pass through the stripper unchanged, but the engine has no decorator syntax, so the result then fails to parse, and the error is reported against your original text. And .tsx is not supported: in our run, the JSX element in const el = <div>{name}</div>; was refused as an angle-bracket cast.

The rewrites are mechanical. Here is status.ts from the full program with its enum and parameter properties written the erasable way, and it now runs:

C#
using RecursiveAI.OneEcma;
 
string ts = """
    const Status = { Open: 0, Shipped: 1 } as const;
    type Status = (typeof Status)[keyof typeof Status];
 
    class Shipment {
      private id: number;
      status: Status;
      constructor(id: number, status: Status) {
        this.id = id;
        this.status = status;
      }
    }
 
    new Shipment(7, Status.Shipped).status;
    """;
var r = ScriptEngine.StripTypes(ts, new() { FileName = "status.ts" });
Console.WriteLine(new ScriptEngine().Evaluate(r.Code!, "status.ts").AsNumber);
Text
1

To catch these constructs while you write the code rather than when it runs, turn on erasableSyntaxOnly in your tsconfig.json. The TypeScript docs say the flag “will cause TypeScript to error on most TypeScript-specific constructs that have runtime behavior.”

How do you execute a TypeScript file?

From .NET, it takes three steps: read the file, strip it, and run the result.

  1. File.ReadAllText("orders.ts") reads the source.
  2. ScriptEngine.StripTypes(source, new TypeStripOptions { FileName = "orders.ts" }) erases the types. Check Success; on failure, render each entry of Diagnostics with DiagnosticRenderer.Render to see why.
  3. Run Code. A file without import or export runs with engine.Evaluate(code, "orders.ts"), which returns the value of its last expression. Set Module = false for such a file, as the full program does. A file with import and export is a module: keep Module = true, give the stripped files to a module host such as InMemoryModuleHost, and run the entry file with engine.EvaluateModule("main.ts"), as in the modules variation.

Pass the same file name to StripTypes and to Evaluate, so reports and err.stack name the .ts file. One import detail from the docs: import type statements disappear completely, while an inline specifier is removed with its comma, so import { type A } from 'm' becomes import { } from 'm', “which still loads the module for its side effects.”

The engine has no console, file system or network unless you provide them, so a script reports results by returning a value or calling a C# function you expose (see Call C# from JavaScript).

Does stripping check types, and is TypeScript slower than JavaScript?

Stripping doesn’t check types at all. In shipping.ts, c.address! tells TypeScript the address is there, and the run fails on a customer without one. Run tsc --noEmit in your build, or rely on your editor, to catch type errors before the code runs.

As for speed, the engine never sees the types. Once they’re stripped, it runs the same JavaScript you would have written without them, so the types don’t change how the code runs. Stripping is one extra pass over the source before the run. To run the same file many times, strip it once and keep the Code.

Which JavaScript engine runs TypeScript in C#?

Here it’s OneEcma, an interpreter for the ES2026 language (ECMA-262) written in C#. It runs inside your process, with no service to call. The changelog says the engine has “no file, network, process or environment access of its own”, and you can limit each run’s fuel, memory, wall-clock time, call depth and string length. Its main feature is its error reports: when a script fails, the report says what went wrong, where the failing value came from and how to fix it. Combined with stripping that keeps positions, a TypeScript failure points at the right line of the .ts file.

The TypeScript docs cover the stripper’s rules in full, including the choices it makes where TypeScript leaves room, and Getting started covers the rest of the engine.

All oneecma recipes