Tsonic GitHub

Emitter Package

The emitter generates C# code from IR.

Entry Points

emitCSharpFiles

Batch emit multiple modules:

const result = emitCSharpFiles(modules, {
  rootNamespace: "MyApp",
  entryPointPath: "/path/to/src/App.ts",
});

if (result.ok) {
  // Map<string, string> of path -> C# code
  for (const [path, code] of result.files) {
    writeFile(path, code);
  }
}

emitModule

Single module emission:

const code = emitModule(module, {
  rootNamespace: "MyApp",
});

Emitter Context

The emitter maintains context during generation:

type EmitterContext = {
  readonly indent: number;
  readonly rootNamespace: string;
  readonly currentModule?: IrModule;
  readonly moduleMap?: ModuleMap;
  readonly exportMap?: ExportMap;
};

Context is passed through and updated immutably:

const withIndent = (ctx: EmitterContext): EmitterContext => ({
  ...ctx,
  indent: ctx.indent + 1,
});

Module Emission

Module Structure

core/module-emitter/:

Each module generates:

  1. Using directives (implicit)
  2. Namespace declaration
  3. Static class wrapper
  4. Body statements
// Generated structure
namespace MyApp.Utils
{
    public static class Math
    {
        // declarations from body
    }
}

Namespace Generation

// src/utils/math.ts with rootNamespace "MyApp"
// -> namespace: MyApp.Utils
//
// See `getNamespaceFromPath()` in `@tsonic/frontend` (supports namingPolicy).

Class Name Generation

// math.ts -> class Math
// todo-list.ts -> class TodoList (default)
//
// See `getClassNameFromPath()` in `@tsonic/frontend` (supports namingPolicy).

Type Emission

Primitive Types

types/primitives.ts:

const primitiveMap: Record<string, string> = {
  number: "double",
  string: "string",
  boolean: "bool",
  null: "object",
  undefined: "object",
};

const emitPrimitiveType = (type: IrPrimitiveType): string => {
  return primitiveMap[type.name] ?? "object";
};

Reference Types

types/references.ts:

const emitReferenceType = (
  type: IrReferenceType,
  ctx: EmitterContext
): string => {
  const name = type.clrType ?? type.name;
  if (type.typeArguments?.length) {
    const args = type.typeArguments.map((t) => emitType(t, ctx)).join(", ");
    return `${name}<${args}>`;
  }
  return name;
};

Array Types

Arrays always emit as native C# arrays:

const emitArrayType = (type: IrArrayType, ctx: EmitterContext): string => {
  const elementType = emitType(type.elementType, ctx);
  return `${elementType}[]`;
};

Tuple Types

types/tuples.ts:

const emitTupleType = (type: IrTupleType, ctx: EmitterContext): string => {
  const elementTypes = type.elementTypes.map((t) => emitType(t, ctx));
  return `ValueTuple<${elementTypes.join(", ")}>`;
};

Example:

// TypeScript
const point: [number, number] = [10, 20];
// Generated C#
ValueTuple<double, double> point = (10.0, 20.0);

Union Types

types/unions.ts:

// Simple nullable
type MaybeString = string | null;
// -> string?

// Complex union
type StringOrNumber = string | number;
// -> object (with runtime checks)

Union Narrowing

The emitter generates narrowed types after type guards:

// TypeScript with type guard
function isDog(pet: Dog | Cat): pet is Dog {
  return "bark" in pet;
}

if (isDog(pet)) {
  pet.bark(); // pet is narrowed to Dog
}
// Generated C# - type is narrowed in the if block
if (isDog(pet))
{
    ((Dog)pet).bark(); // Cast to narrowed type
}

Narrowing contexts include:

  • Type predicate functions (x is T)
  • typeof checks
  • Truthiness checks for nullable types
  • Negated conditions (else branch)

Expression Emission

Literals

expressions/literals.ts:

const emitLiteral = (expr: IrLiteralExpression): string => {
  if (typeof expr.value === "string") {
    return `"${escapeString(expr.value)}"`;
  }
  if (typeof expr.value === "boolean") {
    return expr.value ? "true" : "false";
  }
  if (expr.value === null) {
    return "null";
  }
  return String(expr.value);
};

Binary Expressions

expressions/operators.ts:

const emitBinary = (expr: IrBinaryExpression, ctx: EmitterContext): string => {
  const left = emitExpression(expr.left, ctx);
  const right = emitExpression(expr.right, ctx);
  const op = mapOperator(expr.operator);
  return `(${left}) ${op} (${right})`;
};

const mapOperator = (op: string): string => {
  switch (op) {
    case "===":
      return "=="; // C# equality
    case "!==":
      return "!=";
    case "??":
      return "??"; // Null coalescing
    default:
      return op;
  }
};

Call Expressions

expressions/calls.ts:

const emitCall = (expr: IrCallExpression, ctx: EmitterContext): string => {
  const callee = emitExpression(expr.callee, ctx);
  const args = expr.arguments.map((a) => emitExpression(a, ctx)).join(", ");
  return `${callee}(${args})`;
};

Member Access

expressions/access.ts:

const emitMember = (expr: IrMemberExpression, ctx: EmitterContext): string => {
  const obj = emitExpression(expr.object, ctx);
  if (expr.computed) {
    const prop = emitExpression(expr.property as IrExpression, ctx);
    return `${obj}[${prop}]`;
  }
  const prop = expr.property as string;
  return `${obj}.${prop}`;
};

Statement Emission

Variable Declarations

statements/declarations.ts:

const emitVariableDeclaration = (
  stmt: IrVariableDeclaration,
  ctx: EmitterContext
): string => {
  return stmt.declarations
    .map((decl) => {
      const name = emitPattern(decl.pattern, ctx);
      const type = decl.type ? emitType(decl.type, ctx) : "var";
      const init = decl.init ? emitExpression(decl.init, ctx) : undefined;
      return init ? `${type} ${name} = ${init};` : `${type} ${name};`;
    })
    .join("\n");
};

Pattern Lowering

patterns.ts:

Lowers destructuring patterns to C# statements:

  • Array patterns: Generates indexed access with temporary variables
  • Object patterns: Generates property access with temporary variables
  • Rest patterns: Uses ArrayHelpers.Slice for arrays, synthesized types for objects
  • Default values: Uses null-coalescing operator (??)
  • Nested patterns: Recursively lowers nested structures
// Input: const [first, ...rest] = arr;
// Output:
//   var __arr0 = arr;
//   var first = __arr0[0];
//   var rest = Tsonic.Runtime.ArrayHelpers.Slice(__arr0, 1);

Lowering functions:

  • lowerPattern() - General pattern lowering for declarations
  • lowerForOfPattern() - Pattern lowering in for-of loops
  • lowerParameterPattern() - Parameter destructuring in functions
  • lowerAssignmentPattern() - Assignment expression destructuring

Function Declarations

statements/declarations/functions.ts:

const emitFunction = (
  stmt: IrFunctionDeclaration,
  ctx: EmitterContext
): string => {
  const modifiers = ["public", "static"];
  if (stmt.isAsync) modifiers.push("async");

  const returnType = stmt.returnType ? emitType(stmt.returnType, ctx) : "void";

  const params = stmt.parameters
    .map((p) => `${emitType(p.type, ctx)} ${p.name}`)
    .join(", ");

  const body = emitBlock(stmt.body, ctx);

  return `${modifiers.join(" ")} ${returnType} ${stmt.name}(${params})\n${body}`;
};

Class Declarations

statements/classes/:

const emitClass = (stmt: IrClassDeclaration, ctx: EmitterContext): string => {
  const modifiers = ["public"];
  if (stmt.isAbstract) modifiers.push("abstract");

  let header = `${modifiers.join(" ")} class ${stmt.name}`;
  if (stmt.extends) {
    header += ` : ${emitType(stmt.extends, ctx)}`;
  }

  const members = stmt.members.map((m) => emitClassMember(m, ctx)).join("\n\n");

  return `${header}\n{\n${indent(members)}\n}`;
};

Anonymous Object Synthesis

Object literals without explicit type annotations auto-synthesize nominal classes:

// TypeScript
const point = { x: 10, y: 20 };
// Generated C# - synthesized class
public class __Anon_main_5_15
{
    public double x { get; set; }
    public double y { get; set; }
}

// Usage
var point = new __Anon_main_5_15 { x = 10.0, y = 20.0 };

Synthesized class names follow the pattern: __Anon_{file}_{line}_{col}

Eligible patterns:

  • Property assignments
  • Shorthand properties
  • Arrow function properties

Ineligible patterns (error TSN7405):

  • Method shorthand
  • Getters/setters
  • Spread elements

FQN Emission

Fully qualified names ensure no ambiguity:

emitter-types/fqn.ts:

const emitFQN = (typeName: string, namespace: string): string => {
  return `global::${namespace}.${typeName}`;
};

// Usage in generated code:
// global::System.Console.WriteLine("Hello");

Generic Specialization

specialization/:

Generic types are specialized at use sites:

// TypeScript
function identity<T>(x: T): T { return x; }
const n = identity<number>(42);

// C# (no specialization needed for simple cases)
public static T identity<T>(T x) { return x; }
var n = identity<double>(42);

Complex cases require monomorphization.

Generic null Handling

In generic contexts, TypeScript null emits as C# default:

// TypeScript
function getOrNull<T>(value: T | null): T | null {
  return value ?? null;
}
// Generated C#
public static T? getOrNull<T>(T? value)
{
    return value ?? default; // 'default' instead of 'null'
}

This ensures correct behavior for both reference and value types.

JSON AOT Support

The emitter provides automatic NativeAOT-compatible JSON serialization:

Detection

expressions/calls.ts detects JsonSerializer calls via binding resolution:

const isJsonSerializerCall = (callee: IrExpression): boolean => {
  if (callee.kind !== "memberAccess") return false;
  return callee.memberBinding?.type === "System.Text.Json.JsonSerializer";
};

Type Collection

Types are collected in a shared registry during emission:

type JsonAotRegistry = {
  rootTypes: Set<string>; // e.g., "global::MyApp.User"
  needsJsonAot: boolean;
};

Call Rewriting

Calls are rewritten to use generated options:

// Before: JsonSerializer.Serialize(user)
// After:  JsonSerializer.Serialize(user, TsonicJson.Options)

Context Generation

When needsJsonAot is true, generates __tsonic_json.g.cs:

[JsonSerializable(typeof(global::MyApp.User))]
internal partial class __TsonicJsonContext : JsonSerializerContext { }

internal static class TsonicJson {
    internal static readonly JsonSerializerOptions Options = new() {
        TypeInfoResolver = __TsonicJsonContext.Default
    };
}

Generator Emission

generator-wrapper.ts and generator-exchange.ts:

Simple Generators

Basic generators emit as IEnumerable<T>:

public static IEnumerable<double> counter()
{
    yield return 1.0;
    yield return 2.0;
}

Bidirectional Generators

Generators with TNext type emit with wrapper classes:

function* acc(): Generator<number, void, number> {
  let total = 0;
  while (true) {
    const v = yield total;
    total += v;
  }
}

Generates:

  1. Exchange class for bidirectional communication:
public sealed class acc_exchange
{
    public double? Input { get; set; }
    public double Output { get; set; }
}
  1. Wrapper class with JavaScript-style API:
public sealed class acc_Generator
{
    private readonly IEnumerator<acc_exchange> _enumerator;
    private readonly acc_exchange _exchange;
    private bool _done = false;

    public IteratorResult<double> next(double? value = default) { ... }
    public IteratorResult<double> @return(object? value = default) { ... }
    public IteratorResult<double> @throw(object e) { ... }
}
  1. Core iterator returning IEnumerable<exchange>:
IEnumerable<acc_exchange> __iterator()
{
    var total = 0.0;
    while (true)
    {
        exchange.Output = total;
        yield return exchange;
        var v = exchange.Input ?? 0.0;
        total = total + v;
    }
}

IteratorResult

Located in Tsonic.Runtime:

public readonly record struct IteratorResult<T>(T value, bool done);

Used with fully qualified names to avoid module collisions:

global::Tsonic.Runtime.IteratorResult<double>

Async Generators

Async generators follow the same pattern but with:

  • IAsyncEnumerable<exchange> instead of IEnumerable
  • async methods in wrapper class
  • await foreach for iteration

Module Map

For cross-file import resolution:

type ModuleMap = Map<
  string,
  {
    namespace: string;
    className: string;
    exports: Map<string, ExportInfo>;
  }
>;

Used to resolve imports:

// import { foo } from "./utils.js"
// -> global::MyApp.Utils.foo

Golden Tests

The emitter uses golden tests to verify C# output.

Test Structure

packages/emitter/testcases/
└── common/                    # All test cases
    ├── types/
    │   ├── generics/
    │   ├── type-assertions/
    │   └── anonymous-objects/
    ├── expressions/
    ├── arrays/
    └── attributes/

Test Discovery

golden-tests/discovery.ts:

  • Discovers test cases from testcases/ directory
  • Generates test suites dynamically
const discoverTests = (baseDir: string): TestCase[] => {
  const tests: TestCase[] = [];
  tests.push(...findTestsIn(path.join(baseDir, "common")));
  return tests;
};

Writing Golden Tests

Each test case has:

  1. Input: TestName.ts - TypeScript source
  2. Expected: TestName.golden.cs - Expected C# output
// TestName.ts
export function add(a: number, b: number): number {
  return a + b;
}
// TestName.golden.cs
public static double Add(double a, double b)
{
    return a + b;
}

Updating Golden Files

# Update all golden files
npm run update-golden

# Update specific test
npm run update-golden -- --filter "TypeAssertions"