Tsonic GitHub

Language Guide

Tsonic supports a subset of TypeScript designed for compilation to native code.

Supported Features

Variables and Constants

const name = "Alice";
const age: number = 30;
let count = 0;

Functions

// Function declarations
export function greet(name: string): string {
  return `Hello, ${name}!`;
}

// Arrow functions
const double = (n: number): number => n * 2;

// Async functions
export async function fetchData(): Promise<string> {
  return await someAsyncOperation();
}

Classes

export class Person {
  private name: string;
  private age: number;

  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }

  public greet(): string {
    return `Hello, I'm ${this.name}`;
  }

  public static create(name: string): Person {
    return new Person(name, 0);
  }
}

Interfaces

export interface User {
  id: number;
  name: string;
  email?: string;
}

export interface Repository<T> {
  get(id: number): T | null;
  save(item: T): void;
}

Type Aliases

export type UserId = number;
export type Result<T> = { ok: true; value: T } | { ok: false; error: string };
export type Callback = (value: number) => void;

Enums

export enum Status {
  Pending,
  Active,
  Completed,
}

export enum Color {
  Red = "red",
  Green = "green",
  Blue = "blue",
}

Generics

export function identity<T>(value: T): T {
  return value;
}

export class Container<T> {
  private value: T;

  constructor(value: T) {
    this.value = value;
  }

  get(): T {
    return this.value;
  }
}

Control Flow

import { Console } from "@tsonic/dotnet/System.js";

// If/else
if (condition) {
  doSomething();
} else if (otherCondition) {
  doOther();
} else {
  doDefault();
}

// Switch
switch (value) {
  case 1:
    handleOne();
    break;
  case 2:
    handleTwo();
    break;
  default:
    handleDefault();
}

// Loops
for (let i = 0; i < 10; i++) {
  Console.writeLine(i);
}

for (const item of items) {
  process(item);
}

while (condition) {
  doWork();
}

Error Handling

import { Console } from "@tsonic/dotnet/System.js";

try {
  riskyOperation();
} catch (error) {
  Console.writeLine("Error");
} finally {
  cleanup();
}

throw new Error("Something went wrong");

Arrays

import { Enumerable } from "@tsonic/dotnet/System.Linq.js";

const numbers: number[] = [1, 2, 3];
const mixed: Array<number | string> = [1, "two", 3];

// Arrays emit as native C# arrays (T[])
// Use LINQ for functional-style operations
const doubled = Enumerable.select(numbers, (n: number): number => n * 2);
const filtered = Enumerable.where(numbers, (n: number): boolean => n > 1);

Tuples

Fixed-length arrays with specific element types:

const point: [number, number] = [10, 20];
const record: [string, number, boolean] = ["name", 42, true];

// Access elements
const x = point[0]; // 10
const y = point[1]; // 20

Generates ValueTuple<T1, T2, ...> in C#.

Dictionary and HashSet

Tsonic does not include JavaScript Map/Set in the default globals (see @tsonic/dotnet + System.Collections.Generic instead).

import { Dictionary, HashSet } from "@tsonic/dotnet/System.Collections.Generic.js";

// Dictionary<TKey, TValue> - key-value pairs
const userMap = new Dictionary<string, User>();
userMap.add("alice", alice);
const hasAlice = userMap.containsKey("alice");

// HashSet<T> - unique values
const ids = new HashSet<number>();
ids.add(1);
ids.add(2);
const hasOne = ids.contains(1); // true

Objects

interface Config {
  host: string;
  port: number;
}

const config: Config = {
  host: "localhost",
  port: 8080,
};

// Spread operator
const updated: Config = { ...config, port: 9000 };

Anonymous Object Literals

Simple object literals auto-synthesize types without explicit annotation:

// Auto-synthesized - no error
const point = { x: 1, y: 2 };
const handler = { id: 1, process: (x: number) => x * 2 };

// Method shorthand requires explicit type
interface Handler {
  process(): void;
}
const h: Handler = { process() {} }; // OK with type annotation

Template Literals

const name = "World";
const greeting = `Hello, ${name}!`;
const multiline = `
  Line 1
  Line 2
`;

Destructuring

Tsonic supports full JavaScript destructuring patterns with array and object destructuring.

Array Destructuring

// Basic array destructuring
const [first, second] = [1, 2];

// Rest patterns
const [head, ...tail] = [1, 2, 3, 4, 5];
// head = 1, tail = [2, 3, 4, 5]

// Holes (skip elements)
const [a, , c] = [1, 2, 3];
// a = 1, c = 3 (second element skipped)

// Default values
const [x = 10, y = 20] = [5];
// x = 5, y = 20 (default used for missing element)

Object Destructuring

// Basic object destructuring
const { name, age } = person;

// Property renaming
const { firstName: name, lastName: surname } = user;

// Rest properties
const { id, ...rest } = { id: 1, name: "Alice", age: 30 };
// id = 1, rest = { name: "Alice", age: 30 }

// Default values
const { host = "localhost", port = 8080 } = config;

Nested Patterns

// Nested object destructuring
const {
  address: { city, zip },
} = user;

// Nested array destructuring
const [[a, b], [c, d]] = [
  [1, 2],
  [3, 4],
];

// Mixed nesting
const {
  items: [first, second],
} = order;

For-of Destructuring

import { Console } from "@tsonic/dotnet/System.js";

// Destructure in for-of loops
const entries = [
  ["a", 1],
  ["b", 2],
];
for (const [key, value] of entries) {
  Console.writeLine(`${key}: ${value}`);
}

// Object destructuring in for-of
const users = [{ name: "Alice" }, { name: "Bob" }];
for (const { name } of users) {
  Console.writeLine(name);
}

Parameter Destructuring

import { Console } from "@tsonic/dotnet/System.js";

// Function parameter destructuring
function greet({ name, age }: Person): void {
  Console.writeLine(`Hello ${name}, you are ${age}`);
}

// Array parameter destructuring
function swap([a, b]: [number, number]): [number, number] {
  return [b, a];
}

// With defaults
function connect({ host = "localhost", port = 80 }: Config): void {
  // ...
}

Assignment Destructuring

let a: number, b: number;

// Assign via destructuring (parentheses required)
[a, b] = [1, 2];
({ x: a, y: b } = point);

Optional Chaining and Nullish Coalescing

const name = user?.profile?.name;
const displayName = name ?? "Anonymous";

Module System

Tsonic uses ESM (ECMAScript Modules). Local imports must include a file extension (.js is recommended; .ts is also accepted).

Local Imports

// ✅ Correct - with extension
import { User } from "./models/User.js";
import { formatDate } from "../utils/date.js";

// ❌ Wrong - missing extension
import { User } from "./models/User"; // ERROR

Named Exports/Imports

// utils.ts
export const PI = 3.14159;
export function add(a: number, b: number): number {
  return a + b;
}

// App.ts
import { PI, add } from "./utils.js";

Re-exports

// models/index.ts (barrel file)
export { User } from "./User.js";
export { Product } from "./Product.js";
export type { Order } from "./Order.js";

// App.ts
import { User, Product } from "./models/index.js";

Namespace Imports

import * as utils from "./utils.js";
import { Console } from "@tsonic/dotnet/System.js";
Console.writeLine(utils.PI);
utils.add(1, 2);

.NET Imports

.NET imports are ESM too; use .js module specifiers:

// ✅ Correct
import { Console } from "@tsonic/dotnet/System.js";
import { File } from "@tsonic/dotnet/System.IO.js";

// ❌ Wrong
import { Console } from "@tsonic/dotnet/System";
import { Console } from "@tsonic/dotnet/System.ts";

Entry Point

Every executable needs a main() function exported from the entry point.

Basic Entry Point

import { Console } from "@tsonic/dotnet/System.js";

export function main(): void {
  Console.writeLine("Hello!");
}

Async Entry Point

import { Console } from "@tsonic/dotnet/System.js";

export async function main(): Promise<void> {
  const data = await fetchData();
  Console.writeLine(data);
}

Command-Line Arguments

import { Console } from "@tsonic/dotnet/System.js";

export function main(args: string[]): void {
  for (const arg of args) {
    Console.writeLine(arg);
  }
}

Run with:

./myapp arg1 arg2 arg3

Exit Codes

Return an exit code to indicate success or failure:

import { int } from "@tsonic/core/types.js";

export function main(): int {
  if (errorCondition) {
    return 1; // Error
  }
  return 0; // Success
}

Library Output

For libraries, set output.type to "library" in tsonic.json (or a separate config), then run tsonic build.

{
  "rootNamespace": "MyLib",
  "output": { "type": "library" }
}

Generators

Generator functions compile to C# iterators:

import { Console } from "@tsonic/dotnet/System.js";

function* counter(): Generator<number> {
  let i = 0;
  while (i < 5) {
    yield i++;
  }
}

export function main(): void {
  for (const n of counter()) {
    Console.writeLine(n);
  }
}

See also: Generators Guide for comprehensive coverage including bidirectional generators, async generators, and return values.

Bidirectional Generators

Generators can receive values via next(value):

import { Console } from "@tsonic/dotnet/System.js";

function* accumulator(start: number): Generator<number, void, number> {
  let total = start;
  while (true) {
    const value = yield total;
    total += value ?? 0;
  }
}

export function main(): void {
  const gen = accumulator(10);
  Console.writeLine(gen.next().value); // 10
  Console.writeLine(gen.next(5).value); // 15
  Console.writeLine(gen.next(3).value); // 18
}

Async Generators

import { Console } from "@tsonic/dotnet/System.js";

async function* fetchItems(): AsyncGenerator<string> {
  for (let i = 0; i < 5; i++) {
    await delay(100);
    yield `Item ${i}`;
  }
}

export async function main(): Promise<void> {
  for await (const item of fetchItems()) {
    Console.writeLine(item);
  }
}

Generator Wrapper Methods

Bidirectional generators provide standard JavaScript generator methods:

  • next(value?) - Advances the generator and optionally passes a value
  • return(value) - Terminates the generator and sets the return value
  • throw(error) - Terminates the generator and throws an exception

Limitation: Unlike JavaScript, the .throw() method does NOT inject the exception at the suspended yield point. C# iterators don't support resumption with exceptions. The exception is thrown externally after disposing the enumerator. Code like this will NOT behave the same as JavaScript:

// This JavaScript pattern does NOT work the same in Tsonic:
function* withTryCatch(): Generator<number> {
  try {
    yield 1;
    yield 2; // JS: gen.throw() resumes here with exception
  } catch (e) {
    yield -1; // JS: caught exception, yields -1
  }
}

const gen = withTryCatch();
gen.next(); // { value: 1, done: false }
gen.throw(Error()); // JS: { value: -1, done: false }
// Tsonic: throws immediately

Type Narrowing

Tsonic supports type narrowing through type guards and predicates.

Type Predicates

import { Console } from "@tsonic/dotnet/System.js";

function isString(value: unknown): value is string {
  return typeof value === "string";
}

function process(value: string | number): void {
  if (isString(value)) {
    Console.writeLine(value.toUpperCase()); // value is string here
  } else {
    Console.writeLine(value * 2); // value is number here
  }
}

typeof Guards

import { Console } from "@tsonic/dotnet/System.js";

function handle(value: string | number | boolean): void {
  if (typeof value === "string") {
    Console.writeLine(value.length);
  } else if (typeof value === "number") {
    Console.writeLine(value.toFixed(2));
  } else {
    Console.writeLine(value ? "yes" : "no");
  }
}

Negated Guards

import { Console } from "@tsonic/dotnet/System.js";

function process(value: string | null): void {
  if (value === null) {
    return;
  }
  // value is string here (null eliminated)
  Console.writeLine(value.toUpperCase());
}

function handleOptional(value?: string): void {
  if (!value) {
    return;
  }
  // value is string here
  Console.writeLine(value.length);
}

Compound Guards

interface Cat {
  meow(): void;
}
interface Dog {
  bark(): void;
}

function isCat(pet: Cat | Dog): pet is Cat {
  return "meow" in pet;
}

function isDog(pet: Cat | Dog): pet is Dog {
  return "bark" in pet;
}

function handle(pet: Cat | Dog): void {
  if (isCat(pet) && pet.meow) {
    pet.meow();
  }
}

Unsupported Features

The following TypeScript/JavaScript features are not supported:

Feature Reason Alternative
with statement Deprecated, unpredictable Use explicit property access
Dynamic import() Requires runtime loading Use static imports
import.meta Runtime feature Not available
eval() Cannot compile dynamically Not available
Promise.then/catch Callback chains Use async/await
Decorators Experimental Not supported yet
any type Breaks type safety Use unknown or specific types

Promise Chaining

declare const promise: Promise<number>;
declare function doSomething(result: number): void;

export async function main(): Promise<void> {
  // ❌ Not supported
  // promise.then((result) => doSomething(result));

  // ✅ Use async/await
  const result = await promise;
  doSomething(result);
}

Type Annotations

Explicit type annotations are recommended and sometimes required:

// Function parameters must be typed
import { Console } from "@tsonic/dotnet/System.js";

function greetOk(name: string): void {
  // ✅
  Console.writeLine(name);
}

function greetBad(name) {
  // ❌ Error: parameter needs type
  Console.writeLine(name);
}

// Return types are inferred but can be explicit
function add(a: number, b: number): number {
  return a + b;
}

Namespace and Class Mapping

Tsonic maps your directory structure directly to C# namespaces.

The Mapping Rule

Directory path = C# namespace (default: namingPolicy.namespaces = "clr")

src/models/User.ts  ->  namespace MyApp.Models { class User {} }
src/api/v1/handlers.ts  ->  namespace MyApp.Api.V1 { class Handlers {} }

Root Namespace

Set via CLI or config:

tsonic build src/App.ts --namespace MyApp

Or in tsonic.json:

{
  "rootNamespace": "MyApp"
}

File to Class Mapping

The file name (without .ts) becomes the C# class name:

File Generated Class
App.ts class App
UserService.ts class UserService
my-utils.ts class MyUtils

To override naming, set namingPolicy in tsonic.json. For example, namingPolicy.all = "none" disables CLR-style renaming (only hyphens are removed).

Directory to Namespace Mapping

Each directory becomes a namespace segment:

MyApp/              (root namespace)
├── models/         -> MyApp.Models
│   ├── User.ts     -> MyApp.Models.User
│   └── Product.ts  -> MyApp.Models.Product
└── services/       -> MyApp.Services
    └── api.ts      -> MyApp.Services.Api

Case Preservation

To preserve directory casing, set namingPolicy.namespaces to "none":

src/Models/User.ts   -> MyApp.Models.User  (capital M)
src/models/User.ts   -> MyApp.models.User  (lowercase m)

Be consistent with casing across your project.

Static Container Classes

Files with top-level exports become static classes:

// math.ts
export const pi = 3.14159;
export function add(a: number, b: number): number {
  return a + b;
}

Becomes:

namespace MyApp
{
    public static class Math
    {
        public static readonly double Pi = 3.14159;
        public static double Add(double a, double b)
        {
            return a + b;
        }
    }
}

Importing Across Namespaces

TypeScript imports resolve to C# namespace references:

// src/services/UserService.ts
import { User } from "../models/User.js";

export class UserService {
  getUser(): User {
    return new User("John");
  }
}

Becomes:

namespace MyApp.Services
{
    public class UserService
    {
        public MyApp.Models.User getUser()
        {
            return new MyApp.Models.User("John");
        }
    }
}