Skip to content

Compiler

Edgar Mesquita edited this page Oct 10, 2026 · 52 revisions

The Compiler (CSharpToJs)

🌐 This page in: English · Português

The compiler is the core component that enables the magic of eQuantic.UI. It transforms C# semantics into efficient and readable TypeScript code.

Correctness is held by a differential conformance suite: the same C# is evaluated in .NET and in the embedded Bun, and the two answers must agree. Anything the compiler cannot faithfully translate is a build error with a location (the EQ2xxx diagnostics), never silent wrongness.

🛡️ Boundaries (Server vs Client)

To keep browser code honest, the compiler enforces strict boundaries, inspired by Next.js (Server/Client split) and Flutter (constraints), and validates them before emitting JS:

  • Client components (StatefulComponent / StatelessComponent): UI logic, state management, System.Linq, basic types (string, int, DateTime).
  • Forbidden on the client: System.IO, direct System.Net.Http, blocking .Wait(). File.ReadAllText() in a component body is a build error.
  • The bridge: data fetching goes through methods annotated with [ServerAction] (RPC style). See Security & Server Actions.

🛠️ Compiler Components

1. TypeScriptEmitter

The TypeScriptEmitter is the entry point for generating .ts files. It organizes imports, defines classes, and uses the CSharpToJsConverter to convert method bodies.

2. CSharpToJsConverter

Dispatches every Roslyn node to a strategy — one per construct (BinaryExpressionStrategy, IfStatementStrategy, …) — and returns IR, not text. Statements always build a JsStatement. An expression strategy that has crossed over builds a JsExpr (IExpressionIrStrategy); one that still returns text is spliced as an opaque node, byte-identical to what it always produced. That boundary is what lets the migration proceed one strategy at a time, and tests/eQuantic.UI.Compiler.Tests/Coverage/ir-migration.baseline.txt is the list of text strategies — it only ever shrinks, and a new strategy is born on the IR.

3. SourceMapGenerator and SourceMapComposer

SourceMapGenerator writes a standard V3 map (Base64 VLQ) from each TypeScript module eqc emits back to the .cs lines it came from, naming every file the module was written from (a class that takes an interface's default leads that default to the interface's file), each inside the project, and with the C# itself inside only when the map is full. Bun then maps the JavaScript to that TypeScript, and SourceMapComposer composes the two, in C# and with no npm package, into ONE map from the JavaScript the browser runs to the C#, keeping bun's debugId. Both maps go through one map writer, and that writer, like every JSON file the build writes, through one JSON writer whose escape is System.Text.Json's, so a C# file holding a control character still has a map. What a map carries (EQuanticSourceMaps: full in Debug, none elsewhere, external on request) is on Debug.

4. Symbols first, names only where honest

The converter asks the semantic model before it guesses. A member, a local, a parameter, a static — each emits from its symbol (this.name, Counter.name, a bare name), and Console.WriteLine is console.log because the symbol says System.Console. Name heuristics (a leading underscore, a capitalised property) are legal only where the model cannot be asked — a snippet with no model, a node a strategy rewrote. Under an authoritative model an in-tree name that does not bind is a build error (EQ2006), never a guessed translation.

5. The IR and its writers

Since 0.2.0-preview.36

CodeGen/Ir/ is a small tree with one writer per level — JsExpr → JsStatement → JsClassMember → JsClass → JsModule — and the writers own everything a strategy must never hand-write:

  • Parentheses come from precedence and associativity, never from a template. Before this, f ?? g && g shipped verbatim: C# needs no parentheses there, JavaScript refuses the bare mix, and the whole bundle failed to parse.
  • Single evaluation: a JsTemplate names what it computes ({0} === {0}.normalize()) and the writer binds a part used more than once exactly once — a plain name or literal is inlined, a member read is not (a getter may count). A part inside a function the template defines is bound too, a plain name included, since that function runs once per element: only a literal, this and a lambda written in place stay inside, and so does a part that reads a name the function declares.
  • Layout: one statement per line, blocks indented (JsLayout.Pretty); Compact reproduces the former string world byte for byte, which is how each migration step is proven. A class has one layout rule — a blank line before a member with a body, fields contiguous — and a module is its imports as JsImport records, a blank line, its body.
  • The emitter (TypeScriptEmitter) decides what a module contains and hands nodes to the builder; it assembles no text.

Two nets hold all of it: the component pins (every shared component's generated module, byte for byte — a layout change must be whitespace-only against them) and the conformance suite, which executes every translated shape on both sides and compares the answers. The generated twins under src/eQuantic.UI.Runtime/src/shared/components are pinned, type-checked and tested — not linted: generated code answers to its writer, not to a style guide.

🔄 Supported Strategies

Currently, the compiler supports a wide range of C# constructs:

  • Expressions: Arithmetic, Logical, Ternary, String Interpolation, Null-coalescing (??), Conditional Access (?., ?[]). An argument that awaits behind ?. is awaited where it is written, only when the receiver is not null (Since 0.2.0-preview.60), whatever the receiver is: a call, an element or a property in front of ?. is read once into a variable of the method it is written in (Since 0.2.0-preview.61; until then such a receiver failed the build with EQ1004). A null-conditional read answers null where its value is used, as C# does, never JavaScript's undefined (Since 0.2.0-preview.61). A method group is the delegate C# makes: its receiver is read once, when the delegate is made, base.M calls the base's method on this object, and an extension method's group is bound on the class that declares it, where its call goes. A group over an extension that nothing in the bundle declares, such as list.Any, fails the build with EQ2004 (Since 0.2.0-preview.61)
  • Control Flow: if, switch, for, foreach, while, do-while, break, continue, throw
  • Modern Patterns: Full support for Recursive, Property, Positional, Relational, and Logical patterns (C# 9.0 - 12.0)
  • Resource Management: Support for using statements and using var declarations
  • Exceptions: Full support for try-catch-finally and throw statements (Exception → Error)
  • Indexes and Ranges: Support for index-from-end operator (array[^1] → array[array.length - 1])
  • String Methods: Instance methods (Split, Replace, StartsWith, EndsWith, Contains, Substring, IndexOf, LastIndexOf, PadLeft, PadRight, Trim, TrimStart, TrimEnd, ToUpper, ToLower, ToUpperInvariant, ToLowerInvariant, Insert, Remove, ToCharArray) and static methods (IsNullOrEmpty, IsNullOrWhiteSpace, Join, Concat, Compare, Equals, Format). A method given a StringComparison answers by it, through the runtime, and a search by a culture comparison fails the build (EQ1004): the browser has no culture-aware search (Since 0.2.0-preview.60)
  • Number Methods: Parse and TryParse on every numeric type (int, uint, long, ulong, short, ushort, byte, sbyte, float, double, decimal), read by the runtime with .NET's grammar
  • List Methods: Add, AddRange, Insert, InsertRange, Remove, RemoveAt, RemoveRange, RemoveAll, Clear, IndexOf, LastIndexOf, Find, FindIndex, FindLast, FindLastIndex, FindAll, Exists, TrueForAll, Sort, ForEach, GetRange, CopyTo, BinarySearch
  • List faces: an element read or written through IList<T>, IReadOnlyList<T> or IList → $eq.collections.item(list, i) and $eq.collections.setItem(list, i, value), its Count → $eq.collections.count(list), and ICollection<T>'s own Add and Clear → $eq.collections.add(collection, item) and $eq.collections.clear(collection). Each answers for whichever collection the face holds when it runs: an array by its subscript, the runtime's set, linked list or dictionary by its own members, and a type of your own by its indexer, its Count, its Add and its Clear (Since 0.2.0-preview.61)
  • Array Static Methods: Full support for Array static methods
    • Array.Sort(array) → array.sort() - Sort array in place
    • Array.Sort(array, comparison) → array.sort(comparison) - Sort with custom comparer
    • Array.Reverse(array) → array.reverse() - Reverse array in place
    • Array.Find(array, predicate) → array.find(predicate) - Find first matching element
    • Array.FindIndex(array, predicate) → array.findIndex(predicate) - Find index of first match
    • Array.FindAll(array, predicate) → array.filter(predicate) - Find all matching elements
    • Array.IndexOf(array, value) → array.indexOf(value) - Find index of value
    • Array.LastIndexOf(array, value) → array.lastIndexOf(value) - Find last index of value
    • Array.Exists(array, predicate) → array.some(predicate) - Check if any element matches
    • Array.TrueForAll(array, predicate) → array.every(predicate) - Check if all elements match
    • Array.Clear(array) → array.splice(0) - Clear all elements
    • Array.Resize(ref array, size) → array.length = size - Resize array
  • Enum Methods: an enum has no object of its own in the browser, so the statics of Enum call the runtime's enum functions, $eq.enums, with the enum's shape written at the call: its names, the camelCase names the browser holds, its values and whether it is a [Flags] enum (Since 0.2.0-preview.60; they named an object no module declares, and threw)
    • Enum.Parse<T>(text) and Enum.Parse(typeof(T), text) → $eq.enums.parse(text, shape): a name, names joined by commas or a number, the case kept unless ignoreCase is true, and an error where .NET throws one
    • Enum.TryParse<T>(text, out var result) → true with the value, or false with the enum's default in result, and null for the overload that takes a Type
    • Enum.GetName(value) → the name of the member with that value, or null where none has it
    • Enum.GetNames<T>() and Enum.GetValues<T>() → the names and the values, in the order of the values
    • Enum.IsDefined(typeof(T), value) → whether a member has that value, given as the enum, a number, a declared name, or an object holding any of them
    • value.ToString(), an interpolation and a concatenation write the member's name, a flags combination's set flags (Read, Write), a value no member names as its digits, and nothing for a null nullable enum. ToString("D"), "X", "F" and "G", and an interpolation's format, write what .NET writes, and an alignment pads that text
    • A cast from an object holding the enum keeps it ((Status)Enum.Parse(typeof(Status), text)), a cast between two enums goes through their values, and a value no member names is held as its number
  • Dictionary Methods: every Dictionary, IDictionary and IReadOnlyDictionary, and the sorted ones, is a runtime class under $eq.collections.*, asked with has/get (Since 0.2.0-preview.60)
    • new Dictionary<K, V>() → $eq.collections.dictionary(), or $eq.collections.dictionary(null, true) where the key type compares by value (a record, a struct, a tuple, a decimal, a date, a class that overrides Equals); a copy seeds it with its source, and an initializer with [key, value] pairs
    • dict[key] → $eq.mapGet(dict, key) - throws for a missing key as .NET does; dict[key] = value → $eq.mapSet(dict, key, value), which answers the value written
    • ContainsKey(key) → dict.has(key) - never answers for a prototype member
    • TryGetValue(key, out var value) → (dict.has(key) ? ((value = dict.get(key)), true) : ((value = 0), false)) - a miss writes default(TValue) (0 here, for an int)
    • GetValueOrDefault(key[, defaultValue]) → (dict.has(key) ? dict.get(key) : 0) - an explicit default is evaluated once, whether or not it is needed
    • A receiver or key read through a member or a call is bound once: this.cache.TryGetValue(Next(), out var v) → (($0, $1) => …)(this.cache, next())
    • Add(key, value) → dict.set(key, value) - does NOT throw for a key already present, where .NET's does (#440)
    • Remove(key) → dict.delete(key), and Remove(key, out var value) moves the value into the out
    • TryAdd(key, value) → dict.tryAdd(key, value), ContainsValue(value) → dict.containsValue(value)
    • Clear() → dict.clear()
    • Keys / Values (properties) → dict.keys() / dict.values(), arrays in the dictionary's order; Count, Keys.Count and Values.Count → dict.size, and Keys.Contains(key) → dict.has(key)
  • LINQ: Direct conversion of LINQ methods to JS equivalents:
    • Projection: Select → map, SelectMany → flatMap
    • Filtering: Where → filter, Distinct → [...new Set()]
    • Ordering: OrderBy/OrderByDescending → sort, Reverse → [...arr].reverse()
    • Partitioning: Skip → slice(n), Take → slice(0, n)
    • Element: First/FirstOrDefault → find/[0], Last/LastOrDefault → arr[arr.length-1], Single/SingleOrDefault → find/[0]
    • Quantifiers: Any → some/length > 0, All → every, Contains → includes
    • Aggregation: Count → length/filter().length, Sum → reduce(($a, $b) => $a + $b, 0), Average → reduce()/length, Min → Math.min(...), Max → Math.max(...)
    • Set Operations:
      • Concat(other) → [...source, ...other] - Concatenate two sequences
      • Union(other) → [...new Set([...source, ...other])] - Unique elements from both sequences
      • Intersect(other) → [...new Set(source)].filter(($x) => other.includes($x)) - Common elements
      • Except(other) → [...new Set(source)].filter(($x) => !other.includes($x)) - Elements in source but not in other
      • other, like every argument of a LINQ call that is not a lambda written in place, is evaluated once, in C#'s order, before the lowering runs: it ran once per element (Since 0.2.0-preview.61)
    • Comparers: a comparer handed to ToDictionary, ToLookup, GroupBy, Distinct or ToHashSet that asks for the default (EqualityComparer<T>.Default, null, StringComparer.Ordinal) is dropped, since the lowering already finds its keys that way, and any other fails the build with EQ2007 (Since 0.2.0-preview.61)
    • Type Filtering:
      • Cast<T>() → passthrough (JavaScript is dynamically typed)
      • OfType<T>() → filter(($x) => typeof $x === 'type') for primitives, filter(($x) => $x instanceof Type) for objects
  • Async/Await: Mapping of Task to Promise and native await support.
  • Modern C# Operators: Support for modern C# operators and keywords
    • Null-coalescing assignment: x ??= value → x ?? (x = value) - Assign only if null/undefined
    • nameof operator: nameof(variable) → 'variable' - Get name as string at compile time. It is the name, not its spelling: nameof(@class) → 'class' (Since 0.2.0-preview.60)
    • default keyword: default(T) and the default literal are the type's default, the literal's type being the one C# converts it to: default(int) → 0, default(long) → 0n, default(int?) → null, int x = default → 0 (Since 0.2.0-preview.58)

What C# gives you for free, and JavaScript does not

Two defaults are implicit in C# and absent in JavaScript. Both were emitted as nothing for a while, and both fail LATE, not at build time, and not where the cause is.

An unset value type is ZERO

Since 0.2.0-preview.22

A field of a value type is zero whether or not anyone wrote = 0. On the client it was undefined, and the two are not the same value. Reads survive by luck for as long as they are TESTS (undefined > 0 is false, which is what 0 would have said), and then the first ARITHMETIC turns it into NaN. Math.max(width, undefined) reaches the stylesheet as width:NaNpx, a rule the CSS parser drops whole: the class is computed, hashed, emitted, put on the element, and does nothing.

It shows up on client-RENDERED pages only, never on SSR or a direct load, because the server computes the same property in C# where it was 0 all along. So the two targets disagree about one field and the page that proves it is the one nobody reloads.

Every non-nullable value type now carries its default (0, false, an enum's zero member). Nullable ones do not, because there null IS the C# answer, and inventing a zero would be the same divergence pointing the other way.

A primary-constructor parameter is instance STATE

Since 0.2.0-preview.21

public sealed class TocEntry(Action<string, bool> onSeen, string id) : StatelessComponent
{
    public override VisualNode Build(ComponentContext context) =>
        new InView(Heading(id), visible => onSeen(id, visible));   // this.onSeen, this.id
}

Roslyn models the capture as an IParameterSymbol, so every place that asks "is this a parameter?" answers yes about something that behaves like a field. Emitted bare it compiles, the page renders, and the ReferenceError arrives whenever the callback finally fires, surfacing from inside the reconciler as a TypeError about something else entirely.

Plain models cross too

A component is not the only C# a page needs. The document model behind an editor, a small state machine, a parser: none of them are components, and all of them have to run on both targets. They transpile the same way, as their own modules, and the rules below are what makes the emission CHECKED rather than merely present.

Ranges are slices

line[start..end]   →  line.slice(start, end)
line[2..]          →  line.slice(2)
line[..^1]         →  line.slice(0, -1)
line[..^n]         →  $eq.slice(line, 0, false, n, true)

The last shape is the one JavaScript cannot say directly: ^0 means the END, while slice(0, -0) is slice(0, 0), which is empty. Anything but a positive literal after ^ therefore resolves against the length the way Index.GetOffset does. A Range stored as a VALUE is reported (EQ2004): nothing on the other side receives one, and indexing at the point of use is what it is for.

A range over a type of your own with a Length (or a Count) and a Slice(int start, int length) is not an array's slice: C# lowers it to that Slice, which takes a start and a LENGTH where JavaScript's slice takes an end, and so does the twin (Since 0.2.0-preview.61):

strip[1..3]    →  strip.slice(1, 3 - 1)

The receiver is read once, then the endpoints in the order they are written, then the Length or Count, read only where an endpoint counts from the end or the end is left open, as .NET reads them. A range handed to an indexer that takes the Range itself would hand it a Range value, and fails the build (EQ2004) for the same reason a stored one does.

out and ref

JavaScript has neither. A method that declares them returns an OBJECT (its own value under $, each out and ref under its name) and its body moves inside a closure so every return in it keeps meaning what it meant. The call site unwraps with an arrow, which works in any expression position including inside an if:

var next = document.Replace(range, text, out var caret);
let caret: any;
let next = ($o => (caret = $o.caret, $o.$))(document.replace(range, text));

out leaves the JS parameter list (it is not passed IN); ref stays, because it is read before it is written. out _ assigns nothing.

Expression variables are declared where C# declares them

A pattern's binding (x is T t), an out var and a deconstruction's element ((var c, var d) = …) are assigned inside the expression that declares them, so the statement holding that expression declares them, with the scope Roslyn gives them (Since 0.2.0-preview.59):

  • an expression statement, an if, a return, a throw, a yield return, a declaration, a switch's governing expression and a lock declare them in front of themselves, where the code after them can read them (if (!int.TryParse(s, out var n)) return; and then n). Standing directly in a switch section, they leave them to the switch, which declares them once for its whole block, as C# scopes them, so another section can assign them;
  • a while, a do and a for declare them in the loop's own head, for (let n; …), which JavaScript copies for every iteration, as .NET gives a loop's condition a fresh variable: a closure made in one iteration keeps that iteration's value;
  • a foreach and a using declare them in a block around the statement, which is their scope in C#;
  • a lambda, a local function, a query's clause and a switch expression's arm declare their own, once for every call;
  • a field's or a property's initializer declares them in an arrow of its own.

A name JavaScript refuses, which C#'s verbatim escape allows (@class, @new, @default), becomes one legal name at its declaration and at every reference, whichever way it was bound: class$, with a $ no C# name can hold. A label and a method's type parameter written that way take the same name, and a member or a with key is the name itself: r.class, { class: 5 } (Since 0.2.0-preview.61).

A local named like a global the emitted code reads (crypto, undefined, Math, Number, console) takes the same $, so the global stays visible to the code beside it, and every name the emitted code declares for itself starts with a $, so it never meets a local the code around it reads (Since 0.2.0-preview.61).

Collections: capacity is not contents

new List<T>(x) means two opposite things depending on what x is, and only the resolved constructor can say which: new List<string>(other.Count) is an empty list sized ahead, new List<string>(other) is a copy. The first emits an empty array, the second [...other]. With a collection initializer, the list is what its constructor put in it, then each element in order: new List<string>(other) { "x" } is [...other, "x"] and new List<string>(8) { "x" } is ["x"], written out or target-typed. A capacity that is not a constant the list takes is still evaluated, before the elements, and a negative one is refused as List<T>'s constructor refuses it: new List<string>(Size()) { "x" } is ($eq.collections.listCapacity(this.size()), ["x"]) (Since 0.2.0-preview.61).

Char arithmetic computes on code units

A C# char in + - * / % promotes to int and computes on the code unit, while a transpiled char is a 1-length string. When the RESULT type is numeric, char operands lower to code units (constant literals fold to the number; expressions read charCodeAt(0)), so text[i] - '0' is the digit and 'A' + col is a number, exactly as in .NET. (char)numeric lowers to String.fromCharCode. char + string stays concatenation: its result type is string, so the numeric branch never sees it.

A value from the server crosses a TYPED boundary

JavaScript has no decimal and no 64-bit integer, so the wire sends them as strings — a long as "9007199254740993", a decimal as "0.1", the date family as ISO text. The compiler knows the C# type of every state field and every Server Action's return, so it writes that knowledge into the twin as a static $hydration map, and the runtime coerces ONCE, where the value arrives, instead of every use site coercing defensively. A record names its own map, a list is [spec], a dictionary's values are { dict: spec }, and a tuple is positional ({ tuple: [...] }) because it crosses as an array. Nothing is emitted where hydration would be the identity, which is most fields.

A default is decided by the TYPE, and is never null for a value type

new int[0].SingleOrDefault() is 0 in .NET, not null, and the same goes for FirstOrDefault, LastOrDefault, ElementAtOrDefault and DefaultIfEmpty. A field declared without an initializer takes the same default: an int is 0, a bool false, a long 0n, and an ENUM its zero-valued member — which is a member-NAME string on this side, so a field left unset would otherwise render nothing at all. One table answers for both, by symbol, so a type reached through an alias (using Amount = decimal;) is not a different answer from the type itself.

The same table fills a sized array, new T[n], and answers default(T) and the default literal, whose type is the one C# converts it to: a local's, a parameter's, the other arm's of a conditional. A struct element is a zero instance of its own, since fill would share one object across every slot, and the time and identity twins (DateTime, TimeSpan, DateOnly, TimeOnly, DateTimeOffset, Guid) start at their MinValue, Zero or Empty. A struct whose hand-written twin cannot build its zero keeps the twin's own default in a default literal: undefined, which the twin's constructor turns into its default, where null would bypass it. Since 0.2.0-preview.58.

A second OrderBy restarts the ordering

xs.OrderBy(a).OrderBy(b) sorts by b. The earlier ordering does not stay the primary key — it survives only as the tiebreak a stable sort gives it — so it is a different result from xs.OrderBy(a).ThenBy(b) whenever b has ties that a would break. Both cross faithfully: a chained OrderBy sorts its source first and sorts that, and ThenBy composes its key into the same comparison.

A half rounds to the EVEN neighbour, like .NET

Math.round(32.5) is 33 in JavaScript and 32 in .NET, which rounds a half to even. Everywhere the runtime mirrors a MathF.Round it uses the .NET rule, so a value computed in the browser is the value the server computed — this is what keeps a type scale's line height, and any layout derived from one, identical across hydration rather than a fraction off.

It is .NET's arithmetic, not an approximation of it. The midpoint is EXACT: Math.Round(0.5015, 3) is 0.501, because 0.5015 * 1000 is 501.49999999999994 — a value near a half is not a half. Every MidpointRounding mode is honoured, a digit count past 15 (6 for a float) throws as .NET's does, and MathF.Round(x, digits) scales and divides in single precision.

The LINQ surface is a table, and the numeric BCL is another

Most LINQ operators are one shape each — Where is filter, Aggregate is reduce with its arguments swapped — so they live as entries in a table keyed by operator and argument count, gated once, with the IR writer punctuating and binding any receiver used twice. An operator that has to REASON keeps a strategy of its own: the OrDefault family reads the element type, Sum picks a seed by it, Cast and OfType test types, Contains chooses between identity and structural equality. The same shape holds for the numeric BCL, where the modern surface (Double.AcosPi and its family) is a table of templates rather than a method each.

Where those tables have no entry, the call is a build ERROR naming the member, never a guess: a name emitted with nothing behind it is a ReferenceError at load, and a page that never renders.

Fixed-width integers settle by their type

A C# byte past 255 wraps; a JavaScript number keeps counting. The compiler reads the RESULT type of every + - * << (and of ++, -- and the compound forms) and settles the value where C# would: byte, sbyte, short, ushort and uint ALWAYS wrap (& 0xFF, << 16 >> 16, >>> 0 — packed values and hashes rely on it, so h *= 16777619 on a uint is the FNV step it is in .NET, through Math.imul). int and long wrap only where you wrote unchecked (unchecked(a * a) is 0 for 65536; a long wraps through BigInt.asIntN), because every plain i + 1 in a UI would otherwise carry a | 0 for an overflow that is a bug anywhere else — so a plain int.MaxValue + 1 keeps the double's count, a documented limit. A checked context — the block, the expression, or the project-wide setting, read from the bound tree's IsChecked rather than from the syntax — throws an overflow exactly where C# throws it. A float is a single wherever it is PRODUCED — every + - * /, every increment and compound assignment on a float target (a float? one is not rounded yet), an int past 2^24 on its way in, a float constant, a value hydrated from the server — because RyuJIT rounds each float operation, and so a*x - b*x answers the server's last bit in the browser too. It prints as the shortest decimal that reads back as the same single, so 0.1f + 0.2f is "0.3" on both sides. char++ steps the character; an enum in arithmetic (day + 1, a.CompareTo(b)) computes on the value behind the member name.

Implicit conversions are settled by the bound tree

The syntax never shows an implicit conversion — int i = c with a char, Twice(c), a[c], long l = n — and a rule written against syntax covers only the shapes its author remembered. The compiler reads them from Roslyn's bound tree instead: after every expression is translated, the conversion the bound tree wraps it in is applied (ValueFlow), at every site C# applies it — initializers, arguments, indexes, returns, comparisons. A char promoted to a number becomes its code unit (a constant folds: 'A' + col is 65 + col); an int flowing into a long becomes a BigInt (1 → 1n, which is also why TimeSpan.FromSeconds(90) emits 90n: .NET 9's overload takes a long). Two chars compared stay characters — JavaScript orders 1-length strings by the same code units. A user-defined conversion crosses as a call to the static its twin carries — an app's own type as Money.fromInt(5), a vocabulary type as IconGlyph.fromIcons('search') for Icon(Icons.Search) — unless its twin takes the operand as it is: SizeValue from a number declares exactly that, so Width = 120 stays a plain 120, and a type from outside the SDK (Index) passes its primitive through. A value on its way into TEXT is settled the same way: boxed into a concatenation, a string operand of one, or a plain interpolation hole, it prints as C# prints it — null as nothing, a bool as "True", an enum by its member name — s += flag included, which no syntax rule had seen.

A type's own operators cross

JavaScript cannot overload +, so a record or struct you declare with operators carries each one in its twin as a static method, and every site the bound tree shows an operator at calls it: a + b is Money.opAdd(a, b), -m is Money.opNegate(m) (unary and binary - are named by arity, so they never collide), m += other is m = Money.opAdd(m, other). Conversions too: implicit operator Money(int v) becomes Money.fromInt, called wherever C# converts — a declaration, an argument, the operand of a compound — and explicit operator int(Money m) becomes Money.toInt, called by the cast. Only for types in your source: a framework wrapper such as SizeValue or Index is its primitive on this side, and its operators pass the value through.

The ELEMENT of a foreach converts the same way, one item at a time: foreach (long l in ints) makes each int a BigInt, foreach (int code in chars) each char its code unit, foreach (Money m in ints) calls the type's conversion — the loop binds a raw $l and declares l from it. A using DECLARATION (using var r = …;) owns the rest of its block: what follows runs inside a try whose finally disposes r — after a return value is taken, when the body throws, in reverse order when several share a block — and await using awaits disposeAsync.

Dictionaries enumerate as .NET's do

A dictionary is the runtime's dictionary class, which holds its entries by slot as .NET's Dictionary does: it enumerates in insertion order while nothing is removed, and an entry added after a removal takes the removed one's slot, the last freed first. foreach over one (and new List<KeyValuePair<,>>(dict), which spreads it) yields pairs that destructure as [key, value] AND answer .key/.value (both C# consumption shapes), each key in its own type, so the next key + 1 adds. A sorted dictionary yields the same pairs, in key order. Since 0.2.0-preview.60.

Generic item annotations defer to inference

new List<KeyValuePair<int,float>>(…) cannot annotate its let with the bare C# name (KeyValuePair[] names nothing in TS); generic items leave the annotation to inference.

Statics initialise as a TYPE, on first use

C# initialises a type's statics together: each starts at its zero, then the initialisers run in declaration order, then the static constructor, once, on first use. A type whose statics can observe one another (an initialiser that is not a constant, or a static constructor) holds them all in one $slots object that $init() builds the first time one of them is read or written, and each static is an accessor pair over its slot, so static int A = B + 1; above static int B = 2; reads B's zero and answers 1, as .NET does, and two types whose statics read each other see the zero .NET sees. The same mechanism serves a record, a struct, a class, a static class and a component. Being lazy, it also survives the shared library's import cycles (its modules import each other through one barrel, and whichever loads first sees the other's class as undefined), which is what the lazy getter per static it replaces was for; that getter initialised each static on its own first read, in no order. A type whose initialisers are constants keeps plain fields, each written as the VALUE C# folds it to (static readonly int Max = Default * 2; above const int Default = 50; is 100, where the expression read Default before its declaration defined it), and a static whose zero builds a twin is built on first use too, never while its module is evaluated. A type that declares a static constructor also starts the initialisation first in its instance constructor and in every static method, accessor and operator, since C# runs that constructor before the first instance and the first use of any static member. The statics initialise in declaration order across fields, properties and field-like events; $slots exists before anything runs, so a re-entrant read sees the zero C# sees; the static constructor's body runs in a function of its own after the initialisers, so a return in it ends it alone; and a failure is kept, every use of the type then throwing a TypeInitializationException whose inner exception is the original, as .NET does. Since 0.2.0-preview.61.

What a type ANNOTATION may name

The emitted .ts is type-checked (that is the second of the two layers), so a signature must never introduce a name the module cannot resolve:

C# TypeScript
an enum string, since its runtime representation is the member name
an interface with no emitted twin any
IReadOnlyList<(char, char)> [string, string][]
Action<T>? ((t: T) => void) | null, parenthesised, or the union binds to the return
char string
a name nothing can verify any, because a wrong type is worse than an open one

Records carry the same rules, plus their static fields and their computed properties (a record is a value with BEHAVIOUR, not just its positional members).

The library IS its directory

Both the transpiled set and the runtime's export barrel are generated from the source directory, never from a hand-kept roster, so the embedded library can never drift from the code it is built from.

📝 Conversion Example

C# Source:

private void Increment() {
    Count++;
    if (Count > 10) Console.WriteLine("Max reached");
}

TypeScript Output:

increment() {
    this.count++;
    if (this.count > 10) console.log("Max reached");
}

🎯 Advanced Features Examples

Enum Operations

C# Source:

public enum OrderStatus { Pending, Processing, Shipped, Delivered }

private void HandleStatusChange(string input)
{
    // Parse enum from string (case-insensitive)
    if (Enum.TryParse<OrderStatus>(input, ignoreCase: true, out var status))
    {
        Console.WriteLine($"Status changed to: {status}");
    }

    // Get all enum values for dropdown
    var allStatuses = Enum.GetValues<OrderStatus>();
    foreach (var s in allStatuses)
    {
        Console.WriteLine($"Available status: {s}");
    }

    // Validate enum value
    if (Enum.IsDefined(typeof(OrderStatus), "Shipped"))
    {
        Console.WriteLine("Valid status");
    }
}

TypeScript Output (shape stands for the enum's shape, which eqc writes at each call: { names: ['Pending', 'Processing', 'Shipped', 'Delivered'], keys: ['pending', 'processing', 'shipped', 'delivered'], values: [0, 1, 2, 3], flags: false, digits: 8 }):

handleStatusChange(input: string) {
    let status: any;
    // Parse with TryParse: the enum's default when it fails, as .NET leaves it
    if (((status = $eq.enums.tryParse(input, shape, true)) !== undefined || ((status = $eq.enums.zero(shape)), false))) {
        console.log(`Status changed to: ${$eq.enums.text(status, shape)}`);
    }

    // Get all values, in the order of the values
    let allStatuses = $eq.enums.values(shape);
    for (const s of allStatuses) {
        console.log(`Available status: ${$eq.enums.text(s, shape)}`);
    }

    // Validate a declared name
    if ($eq.enums.isDefined('Shipped', shape, 'name')) {
        console.log('Valid status');
    }
}

Dictionary Operations

C# Source:

private Dictionary<string, int> _settings = new();

private void ManageSettings()
{
    // Add entries
    _settings.Add("timeout", 5000);
    _settings.Add("retries", 3);

    // Check existence
    if (_settings.ContainsKey("timeout"))
    {
        var timeout = _settings["timeout"];
        Console.WriteLine($"Timeout: {timeout}");
    }

    // Safe retrieval
    if (_settings.TryGetValue("maxItems", out var max))
    {
        Console.WriteLine($"Max: {max}");
    }

    // Iterate keys
    foreach (var key in _settings.Keys)
    {
        Console.WriteLine($"{key} = {_settings[key]}");
    }

    // Clear all
    _settings.Clear();
}

TypeScript Output:

constructor() {
    this._settings = $eq.collections.dictionary();
}

declare _settings: any;

manageSettings() {
    let max: any;
    this._settings.set('timeout', 5000);
    this._settings.set('retries', 3);
    if (this._settings.has('timeout')) {
        let timeout = $eq.mapGet(this._settings, 'timeout');
        console.log(`Timeout: ${timeout}`);
    }
    if ((($0: any) => ($0.has('maxItems') ? ((max = $0.get('maxItems')), true) : ((max = 0), false)))(this._settings)) {
        console.log(`Max: ${max}`);
    }
    for (const key of this._settings.keys()) {
        console.log(`${key ?? ''} = ${$eq.mapGet(this._settings, key)}`);
    }
    this._settings.clear();
}

LINQ Set Operations

C# Source:

private void ProcessCollections()
{
    var list1 = new[] { 1, 2, 3, 4 };
    var list2 = new[] { 3, 4, 5, 6 };

    // Concatenate two lists
    var combined = list1.Concat(list2);
    // Result: [1, 2, 3, 4, 3, 4, 5, 6]

    // Union - unique elements from both
    var union = list1.Union(list2);
    // Result: [1, 2, 3, 4, 5, 6]

    // Intersect - common elements
    var common = list1.Intersect(list2);
    // Result: [3, 4]

    // Except - elements in list1 but not in list2
    var difference = list1.Except(list2);
    // Result: [1, 2]

    // Complex filtering with set operations
    var activeUsers = GetActiveUsers();
    var premiumUsers = GetPremiumUsers();

    // Users that are both active AND premium
    var activePremium = activeUsers.Intersect(premiumUsers);

    // Users that are active but NOT premium
    var activeFree = activeUsers.Except(premiumUsers);
}

TypeScript Output:

processCollections() {
    const list1 = [1, 2, 3, 4];
    const list2 = [3, 4, 5, 6];

    // Concatenate
    const combined = [...list1, ...list2];

    // Union (with Set to remove duplicates)
    const union = [...new Set([...list1, ...list2])];

    // Intersect (common elements)
    const common = [...new Set(list1)].filter(x => list2.includes(x));

    // Except (difference)
    const difference = [...new Set(list1)].filter(x => !list2.includes(x));

    // Complex filtering
    const activeUsers = this.getActiveUsers();
    const premiumUsers = this.getPremiumUsers();

    const activePremium = [...new Set(activeUsers)].filter(x => premiumUsers.includes(x));
    const activeFree = [...new Set(activeUsers)].filter(x => !premiumUsers.includes(x));
}

Array Static Methods

C# Source:

private void ProcessArrayOperations()
{
    var numbers = new[] { 5, 2, 8, 1, 9 };
    var items = new[] { "apple", "banana", "cherry" };

    // Sort array in place
    Array.Sort(numbers);
    // Result: [1, 2, 5, 8, 9]

    // Sort with custom comparison
    Array.Sort(items, (a, b) => b.Length - a.Length);
    // Result: ["banana", "cherry", "apple"]

    // Reverse array
    Array.Reverse(numbers);
    // Result: [9, 8, 5, 2, 1]

    // Find operations
    var firstEven = Array.Find(numbers, n => n % 2 == 0);
    var firstEvenIndex = Array.FindIndex(numbers, n => n % 2 == 0);
    var allEvens = Array.FindAll(numbers, n => n % 2 == 0);

    // Search operations
    var index = Array.IndexOf(numbers, 5);
    var lastIndex = Array.LastIndexOf(numbers, 5);

    // Check operations
    var hasEven = Array.Exists(numbers, n => n % 2 == 0);
    var allPositive = Array.TrueForAll(numbers, n => n > 0);

    // Clear and resize
    Array.Clear(numbers);
    Array.Resize(ref items, 5);  // Expand to 5 elements
}

TypeScript Output:

processArrayOperations() {
    const numbers = [5, 2, 8, 1, 9];
    const items = ["apple", "banana", "cherry"];

    // Sort
    numbers.sort();

    // Sort with comparison
    items.sort((a, b) => b.length - a.length);

    // Reverse
    numbers.reverse();

    // Find operations
    const firstEven = numbers.find(n => n % 2 == 0);
    const firstEvenIndex = numbers.findIndex(n => n % 2 == 0);
    const allEvens = numbers.filter(n => n % 2 == 0);

    // Search operations
    const index = numbers.indexOf(5);
    const lastIndex = numbers.lastIndexOf(5);

    // Check operations
    const hasEven = numbers.some(n => n % 2 == 0);
    const allPositive = numbers.every(n => n > 0);

    // Clear and resize
    numbers.splice(0);
    items.length = 5;
}

LINQ Type Filtering (Cast & OfType)

C# Source:

private void FilterByType()
{
    // Mixed type collection
    object[] mixed = new object[] { 1, "hello", 2, "world", 3.14, true };

    // Cast<T>() - assumes all elements are of type T (passthrough in JS)
    var assumedStrings = mixed.Cast<string>();

    // OfType<T>() - filters to only elements of type T
    var onlyStrings = mixed.OfType<string>();
    // Result: ["hello", "world"]

    var onlyNumbers = mixed.OfType<int>();
    // Result: [1, 2]

    // Works with custom classes too
    var shapes = new object[] { new Circle(), new Square(), new Circle() };
    var circles = shapes.OfType<Circle>();
    // Result: [Circle, Circle]

    // Primitive type filtering
    var primitives = new object[] { 1, "text", 2.5, true, null };
    var strings = primitives.OfType<string>();  // ["text"]
    var numbers = primitives.OfType<double>();  // [1, 2.5]
    var booleans = primitives.OfType<bool>();   // [true]
}

TypeScript Output:

filterByType() {
    // Mixed type collection
    const mixed = [1, "hello", 2, "world", 3.14, true];

    // Cast - passthrough (JS is dynamically typed)
    const assumedStrings = mixed;

    // OfType - filter by typeof for primitives
    const onlyStrings = mixed.filter(x => typeof x === 'string');
    // Result: ["hello", "world"]

    const onlyNumbers = mixed.filter(x => typeof x === 'number');
    // Result: [1, 2, 3.14]

    // OfType - filter by instanceof for objects
    const shapes = [new Circle(), new Square(), new Circle()];
    const circles = shapes.filter(x => x instanceof Circle);
    // Result: [Circle, Circle]

    // Primitive filtering
    const primitives = [1, "text", 2.5, true, null];
    const strings = primitives.filter(x => typeof x === 'string');  // ["text"]
    const numbers = primitives.filter(x => typeof x === 'number');  // [1, 2.5]
    const booleans = primitives.filter(x => typeof x === 'boolean'); // [true]
}

Modern C# Operators

C# Source:

private void DemonstrateModernOperators()
{
    // Null-coalescing assignment (??=)
    string? cachedData = null;
    cachedData ??= LoadDataFromDatabase();  // Only loads if null
    cachedData ??= "Default";               // Won't execute, already assigned

    // Property null-coalescing assignment
    if (user.Settings ??= new Settings())
    {
        Console.WriteLine("Created new settings");
    }

    // nameof operator (useful for property binding, validation)
    var propertyName = nameof(user.Email);
    Console.WriteLine($"Validating {propertyName}");  // "Validating Email"

    var methodName = nameof(ProcessOrder);
    LogAction(methodName);  // "ProcessOrder"

    // default keyword - type-safe default values
    int count = default(int);           // 0
    string? text = default(string);     // null
    bool flag = default(bool);          // false
    DateTime date = default(DateTime);  // 1/1/0001 12:00:00 AM

    // default literal (contextual)
    int number = default;               // 0 (inferred from type)
    ProcessData(default);               // passes default value for parameter type
}

private void ProcessData(int value = default)
{
    // value defaults to 0 if not provided
}

TypeScript Output:

demonstrateModernOperators() {
    // Null-coalescing assignment
    let cachedData = null;
    cachedData ?? (cachedData = this.loadDataFromDatabase());
    cachedData ?? (cachedData = 'Default');

    // Property assignment
    if (this.user.settings ?? (this.user.settings = new Settings())) {
        console.log('Created new settings');
    }

    // nameof operator
    const propertyName = 'Email';
    console.log(`Validating ${propertyName}`);

    const methodName = 'ProcessOrder';
    this.logAction(methodName);

    // default keyword
    let count = 0;
    let text = null;
    let flag = false;
    let date = $eq.time.dateTime.minValue();

    // default literal
    let number = 0;
    this.processData(0);
}

processData(value = 0) {
    // value defaults to 0
}

String Methods - Additional Examples

C# Source:

private void StringManipulation()
{
    var text = "  Hello World  ";

    // Trimming
    var trimmed = text.Trim();              // "Hello World"
    var leftTrim = text.TrimStart();        // "Hello World  "
    var rightTrim = text.TrimEnd();         // "  Hello World"

    // Case conversion
    var upper = text.ToUpper();             // "  HELLO WORLD  "
    var lower = text.ToLower();             // "  hello world  "
    var upperInv = text.ToUpperInvariant(); // "  HELLO WORLD  "
    var lowerInv = text.ToLowerInvariant(); // "  hello world  "

    // Chaining methods
    var clean = text.Trim().ToLower().Replace("world", "everyone");
    // Result: "hello everyone"
}

TypeScript Output:

stringManipulation() {
    const text = "  Hello World  ";

    // Trimming
    const trimmed = $eq.text.trim(text);
    const leftTrim = $eq.text.trimStart(text);
    const rightTrim = $eq.text.trimEnd(text);

    // Case conversion
    const upper = text.toUpperCase();
    const lower = text.toLowerCase();
    const upperInv = text.toUpperCase();
    const lowerInv = text.toLowerCase();

    // Chaining
    const clean = $eq.text.trim(text).toLowerCase().replaceAll("world", "everyone");
}

Trimming goes through the runtime because JavaScript's trim is a different set from .NET's: it leaves U+0085 NEXT LINE, which .NET trims, and takes U+FEFF, which .NET keeps. char.IsWhiteSpace, string.IsNullOrWhiteSpace and a bare Split() read the same list, so a text trims, splits and tokenizes alike on both sides (Since 0.2.0-preview.58).

Clone this wiki locally