eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
DocsCompilation
The Compiler (CSharpToJs)
Edit this page
11 min read
🌐 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
A implementation based on the Strategy pattern that traverses the Roslyn syntax tree (AST).
Each type of C# expression or statement has a dedicated strategy (e.g., BinaryExpressionStrategy, IfStatementStrategy).
3. SourceMapGenerator
Generates standard V3 Source Maps with Base64 VLQ encoding, mapping generated JavaScript/TypeScript back to the original .cs or .eqx source lines.
4. Identifier Heuristics
The converter applies intelligent heuristics to decide how to map variable names:
C# properties and fields are mapped to this.propertyName in JS.
Local variables and parameters retain their original names.
System methods like Console.WriteLine are automatically mapped to console.log.
🔄 Supported Strategies
Currently, the compiler supports a wide range of C# constructs:
Expressions: Arithmetic, Logical, Ternary, String Interpolation, Null-coalescing (??), Conditional Access (?., ?[])
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)
Number Methods: int.Parse, double.Parse, float.Parse, decimal.Parse, long.Parse, int.TryParse, double.TryParse
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
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: Full enum operations support
Enum.Parse<T>(string)parseEnum(value, EnumType) (case-insensitive)
Enum.TryParse<T>(string, out var result)(result = parseEnum(value, EnumType), result !== undefined)
Enum.GetValues<T>()Object.values(EnumType) - Get all enum values
Enum.GetNames<T>()Object.keys(EnumType) - Get all enum member names
Enum.IsDefined(typeof(T), value)(EnumType[value] !== undefined) - Validate enum value
Dictionary Methods: Complete Dictionary/IDictionary support
ContainsKey(key)(key in dict) - Check if key exists
TryGetValue(key, out var value)(value = dict[key]) !== undefined - Safe value retrieval
Add(key, value)dict[key] = value - Add or update entry
Remove(key)delete dict[key] - Remove entry
Clear()Object.keys(dict).forEach(k => delete dict[k]) - Remove all entries
Keys (property) → Object.keys(dict) - Get all keys as array
Values (property) → Object.values(dict) - Get all values as array
LINQ: Direct conversion of LINQ methods to JS equivalents:
Projection: Selectmap, SelectManyflatMap
Filtering: Wherefilter, Distinct[...new Set()]
Ordering: OrderBy/OrderByDescendingsort, Reverse[...arr].reverse()
Partitioning: Skipslice(n), Takeslice(0, n)
Element: First/FirstOrDefaultfind/[0], Last/LastOrDefaultarr[arr.length-1], Single/SingleOrDefaultfind/[0]
Quantifiers: Anysome/length > 0, Allevery, Containsincludes
Aggregation: Countlength/filter().length, Sumreduce((a,b) => a+b, 0), Averagereduce()/length, MinMath.min(...), MaxMath.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
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 ??= valuex ?? (x = value) - Assign only if null/undefined
nameof operator: nameof(variable)'variable' - Get name as string at compile time
default keyword: default(int)0, default(string)null, defaultundefined - Get default value for type
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
1
2
3
4
5
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
1
2
3
4
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.
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:
1
var next = document.Replace(range, text, out var caret);
1
2
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.
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 [], the second [...other].
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.
Dictionaries enumerate as pairs
A transpiled primitive-keyed Dictionary is a plain object, and not iterable, so foreach over one (and new List<KeyValuePair<,>>(dict)) lowers through $eq.entries(obj, numericKeys): pairs that destructure as [key, value] AND answer .key/.value (both C# consumption shapes), with numeric keys restored as numbers (Object.entries strings them, and a stringified key would turn the next key + 1 into concatenation). Record/struct-keyed dictionaries keep their valueMap lowering; only primitive keys take this path.
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 LAZILY
The shared library's modules import each other through one barrel, so a static field initialised at module-evaluation time can see another module's class as undefined. Any initialiser that names another module becomes a lazy getter backed by a private slot, which is also the faithful translation, since C# initialises a type's statics on first use. Pure literals stay fields.
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:
1
2
3
4
private void Increment() {
Count++;
if (Count > 10) Console.WriteLine("Max reached");
}
TypeScript Output:
1
2
3
4
increment() {
this.count++;
if (this.count > 10) console.log("Max reached");
}
🎯 Advanced Features Examples
Enum Operations
C# Source:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public enum OrderStatus { Pending, Processing, Shipped, Delivered }
private void HandleStatusChange(string input)
{
// Parse enum from string (case-insensitive)
if (Enum.TryParse<OrderStatus>(input, 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
handleStatusChange(input: string) {
// Parse with TryParse
if ((status = parseEnum(input, OrderStatus), status !== undefined)) {
console.log(`Status changed to: ${status}`);
}
// Get all values
const allStatuses = Object.values(OrderStatus);
for (const s of allStatuses) {
console.log(`Available status: ${s}`);
}
// Validate
if ((OrderStatus['Shipped'] !== undefined)) {
console.log('Valid status');
}
}
Dictionary Operations
C# Source:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private _settings: Record<string, number> = {};
manageSettings() {
// Add entries
this._settings['timeout'] = 5000;
this._settings['retries'] = 3;
// Check existence
if (('timeout' in this._settings)) {
const timeout = this._settings['timeout'];
console.log(`Timeout: ${timeout}`);
}
// Safe retrieval
if ((max = this._settings['maxItems']) !== undefined) {
console.log(`Max: ${max}`);
}
// Iterate keys
for (const key of Object.keys(this._settings)) {
console.log(`${key} = ${this._settings[key]}`);
}
// Clear all
Object.keys(this._settings).forEach(k => delete this._settings[k]);
}
LINQ Set Operations
C# Source:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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 = null;
// default literal
let number = undefined;
this.processData(undefined);
}
processData(value = 0) {
// value defaults to 0
}
String Methods - Additional Examples
C# Source:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
stringManipulation() {
const text = " Hello World ";
// Trimming
const trimmed = text.trim();
const leftTrim = text.trimStart();
const rightTrim = text.trimEnd();
// Case conversion
const upper = text.toUpperCase();
const lower = text.toLowerCase();
const upperInv = text.toUpperCase();
const lowerInv = text.toLowerCase();
// Chaining
const clean = text.trim().toLowerCase().replaceAll("world", "everyone");
}