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.
dotnet add package RecursiveAI.OneEcmaRequires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.
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:
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:
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:
enum Status { Open, Shipped } class Shipment { constructor(private id: number, public status: Status) {} } new Shipment(7, Status.Open);
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:
--- 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:
using RecursiveAI.OneEcma; string ts = "const price: number = (input as any).price!;"; Console.WriteLine(ts); Console.WriteLine(ScriptEngine.StripTypes(ts).Code);
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:
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);
14Call a TypeScript function from C#. Run the stripped code once, then call the function it defined:
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);
23.99TypeScript 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:
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);
$19.99A syntax error is reported as a SyntaxError with its place in the TypeScript:
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]));
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:
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);
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:
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!)); }
error[Limit]: Execution exceeded the step limit of 1000000 steps --> loop.ts:2:1 | 2 | while (true) { n++; } | ^^^^^^^^^^^^^^^^^^^^^ | = stack: <top-level> (loop.ts:2)
Related recipes
- Run JavaScript in C#:
Evaluate, passing values in and reading results out. - Call a JavaScript function from C#
- Run untrusted JavaScript safely in C#: every limit, and what each report looks like.
- Map JavaScript errors back to TypeScript with source maps: for TypeScript you compile rather than strip.
- Get readable JavaScript error messages in C#
- Call C# from JavaScript
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.tsfile. - 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
.tsfile, and runtime reports,err.stackand 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 andthisparameters; - type parameters and arguments on declarations and calls, and generic arrow functions;
as T,as const,satisfies Tand the non-null!;interfaceandtypedeclarations,declaredeclarations, overload signatures, abstract methods and fields, index signatures,implementsclauses;- namespaces and modules whose body holds only types;
import typeandexport typestatements, and inlinetypespecifiers;- the modifiers
public,private,protected,readonly,override,abstractanddeclareon 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.”
using RecursiveAI.OneEcma; string ts = "let x = f\ntype T = number\n(g)()"; Console.WriteLine(ScriptEngine.StripTypes(ts).Code);
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:
| Code | Construct | Write instead |
|---|---|---|
ts-enum | enum, const enum | a const object with as const |
ts-namespace | namespace or module that declares values | a module or a plain object |
ts-parameter-property | constructor(private x) | declare the field, assign it in the constructor |
ts-import-equals | import x = require(), import A = B.C | a standard import, or a const |
ts-export-assignment | export = x | export default or named exports |
ts-angle-cast | <T>expr | expr as T |
ts-parameter-decorator | m(@dec x) | pass the value explicitly |
ts-arrow-return-type-layout | an 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:
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);
1To 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.
File.ReadAllText("orders.ts")reads the source.ScriptEngine.StripTypes(source, new TypeStripOptions { FileName = "orders.ts" })erases the types. CheckSuccess; on failure, render each entry ofDiagnosticswithDiagnosticRenderer.Renderto see why.- Run
Code. A file withoutimportorexportruns withengine.Evaluate(code, "orders.ts"), which returns the value of its last expression. SetModule = falsefor such a file, as the full program does. A file withimportandexportis a module: keepModule = true, give the stripped files to a module host such asInMemoryModuleHost, and run the entry file withengine.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.