This page covers the differences from TypeScript — enough for most humans. See the full syntax reference for everything including TypeScript, or the AI agent reference for a compact token-efficient version suited for AGENTS.md.

VexaScript vs TypeScript: Syntax Differences

This document summarises the syntax additions and differences that VexaScript introduces on top of TypeScript. Everything valid in TypeScript that is not overridden here continues to work the same way.

Character and string literals

In .vx files, single quotes denote an integer Unicode code point, while double quotes denote a string:

val letter: int = 'a' // 97
val emoji: int = '😀' // 128512
val text: string = "aaa"
val matches = "aaa".charCodeAt(0) == 'a'

A single-quoted literal must contain exactly one decoded Unicode code point. 'aaa' and '' are errors; the editor quick fix converts them to double-quoted strings. Character literals emit as direct integer constants in JavaScript and C++, avoiding one-character string allocation. TypeScript mode is unchanged, so single-quoted literals remain strings in .ts, .tsx, and .d.ts files.

String-template shorthand

Backtick templates accept both the JavaScript-compatible ${expression} form and a compact $identifier form. Escape a literal dollar sign as \$.

val name = "Ada"
val greeting = `Hello $name; next year you are ${age + 1}`

TypeScript requires ${name} for the first interpolation.

Variable declarations

val keyword

VexaScript adds val as an immutable binding keyword, complementing TypeScript's const. Use val for immutable bindings—it is idiomatic in VexaScript.

val name: string = "Ada"   // VexaScript
const name: string = "Ada" // TypeScript equivalent

Destructuring: :: for renaming and : for inline types

In VexaScript destructuring, the colon (:) introduces an inline type annotation, and the double-colon (::) renames a source property to a local binding. This is the reverse of TypeScript, where : renames.

// VexaScript
let { name :: displayName, age: number } = person
//          ^^^ renames    ^^^ type annotation
// TypeScript equivalent
let { name: displayName, age }: { name: string; age: number } = person

Functions

fun keyword

VexaScript adds fun as a concise alternative to function.

fun add(a: number, b: number): number {
  return a + b
}

=> shorthand for single-expression bodies

Named functions and class methods can use => to return a single expression, without a block body.

fun double(x: number): number => x * 2

class Point(val x: number, val y: number) {
  distanceTo(other: Point): number => Math.hypot(x - other.x, y - other.y)
}

sync functions (implicit auto-await)

The sync modifier declares a function that behaves like async but automatically awaits any Promise<T> sub-expression used as a value. The return type is written without the Promise<...> wrapper.

// VexaScript
sync fun loadUser(id: string): User {
  val data = fetchJson(`/users/${id}`)   // auto-awaited; data: User
  return data
}
// TypeScript equivalent
async function loadUser(id: string): Promise<User> {
  const data = await fetchJson(`/users/${id}`);
  return data;
}

Auto-await applies at every use site: call arguments, array elements, object properties, member receivers, operands. Bare local variable reads are not auto-awaited—only the point where a Promise is produced.

go operator (opt-out of auto-await)

Inside a sync function, prefix any expression with go to keep the Promise<T> value instead of awaiting it.

sync fun demo(): void {
  val pending: Promise<User> = go fetchUser(id)  // fire-and-forget or store
  go fetchUser(id).then(process)                 // chain without awaiting
}

go is only valid inside sync functions and is a contextual keyword (existing go identifiers still work outside sync).

Named arguments

Arguments can be passed by parameter name in any order, freely mixed with leading positional arguments.

fun connect(host: string, port: number): void { }

connect(port: 8080, host: "localhost")   // reordered automatically
connect("localhost", port: 8080)          // mixed positional + named

TypeScript has no named-argument syntax.

Tail lambdas and brace lambdas

A lambda can be written after the closing parenthesis of a call (or omitting the parens entirely), following the Kotlin/Swift convention.

[1, 2, 3].map { it * 2 }
[1, 2, 3].filter { it > 1 }
[1, 2, 3].map { n: number -> n * 2 }

Brace lambdas are also valid anywhere an expression is accepted:

useEffect({
  val timeout = setTimeout({
    refresh()
  }, 1000)
  return { clearTimeout(timeout) }
}, [count])

When a brace lambda appears as a trailing lambda or as a brace-lambda argument, the shorthand { body } form gets the implicit it parameter. In ordinary expression positions, the same shorthand is a zero-argument lambda unless an explicit parameter list is written with ->.

Inside an argument list, { name } remains context-sensitive: it can be interpreted semantically as a one-parameter lambda (implicit it) when the expected parameter type is a function, and as a shorthand object literal otherwise. The explicit { arg -> body } form is always a lambda.

TypeScript uses inline arrow functions: [1,2,3].map(it => it * 2).

Receiver function types and blocks

A function type can declare an implicit receiver before its parameters:

fun <T> T.configure(block: T.() -> void): T {
  block(this)
  return this
}

val point = Point(1, 2).configure {
  x *= 2
  y += x
}

T.(arg: A) -> R is represented at runtime as (T, A) -> R, so a direct call passes the receiver first. Inside a contextually typed brace lambda, unqualified members and this refer to the receiver. Implicit it is the first visible parameter when one exists; for T.() -> R, it aliases the receiver. Nested receivers can be selected explicitly with this@functionName.

Optional and non-null asserted member access use the same extension-method resolution as ordinary member access. value?.method(args) guards the extension call for a nullish receiver, while value!.method(args) resolves it after removing nullish types.

@JsInline annotation

A bodyless function with @JsInline provides a raw JavaScript template inserted at each call site.

@JsInline("if (!cond) throw new Error(message)")
fun assert(cond: boolean, message: string = "assert failed")

assert(value > 0)

annotation declarations and @JsName

Annotations are declared explicitly and then applied with @:

annotation Benchmark
annotation JsName(val name: string)
annotation JsInline(val replacement: string)

Zero-argument annotations may omit parentheses in both declarations and use sites:

annotation Benchmark

@Benchmark
fun measure() {}

@JsName overrides the emitted JavaScript name while keeping the source name for VexaScript analysis.

@JsName("rgba")
class Color(val r: int, val g: int, val b: int, val a: int)

val white = Color(255, 255, 255, 255)  // emits: new rgba(255, 255, 255, 255)

Annotations may also be attached directly to class fields, accessors, and methods. Their arguments are checked like top-level annotation applications and the annotations themselves are erased unless a compiler-recognized annotation defines lowering behavior.

Native implementation and FFI annotations

@CppHeader, @CppFlags, and @CppBody attach trusted C++ source and build metadata to a signature-only function. @FFILibrary("candidate", ...) attaches an ordered dynamic-library search list to an ambient class whose static methods name C symbols. @FFIName optionally separates the imported symbol from the source method name. Native builds use cached LibraryOpen symbol resolution; JavaScript uses Deno FFI or a compatible globalThis.VexaFFI adapter. See docs/syntax.md for @FFIStruct layouts, FFIPointer, asynchronous Promise<T> calls, and the complete type and security contract.

Test files

vexa test discovers .test.vx files and supplies Node-compatible test and strict assert helpers without imports:

test("arithmetic") {
  assert(1 + 1 == 2)
}

The test name is reported by Node's test runner, and runner flags can be passed through the CLI.

Classes

Primary constructors

Classes can declare their constructor parameters inline after the class name (Kotlin-style). Parameters prefixed with val, var, let, or const become instance properties automatically.

class Point(val x: number, val y: number)
class User(val name: string, var age: int = 0)
// TypeScript equivalent
class Point {
  constructor(readonly x: number, readonly y: number) {}
}

Optional braces for empty classes

When a class has no members (only the primary constructor or nothing), the braces can be omitted.

class Tag
class Point(val x: number, val y: number)

Class instantiation without new

A declared class can be called directly without new.

val p = Point(1, 2)          // VexaScript
const p = new Point(1, 2);   // TypeScript equivalent

new ClassName(...) is still valid and accepted.

Implicit this member access

Inside class methods and field initializers, class members can be referenced without the this. prefix. Local variables and parameters shadow members with the same name.

class Counter(val value: int) {
  fun increment(amount: int): int {
    return value + amount  // emits: return this.value + amount
  }
}

Explicit member kinds in classes and interfaces

Inside class and interface bodies, VexaScript also supports Kotlin/Swift-style member keywords so the declaration kind is visible at a glance. The older TypeScript-style member syntax still works, but fun and val/var/let/const are the preferred spellings.

interface Shape {
  val area: number
  fun draw(ctx: CanvasCtx): void
}

class Rect {
  val width: number
  var height: number

  fun area(): number => width * height
}

Compound accessor blocks

A property may group its getter and setter under one declaration. The default setter parameter is newValue; set(name) or set(name: Type) overrides it. The order of get and set does not matter.

class Counter {
  private var stored = 0

  var value: int {
    get { return stored }
    set(next) { stored = next }
  }
}

The form lowers through the same property-accessor path in JavaScript and C++.

Operator overloads

Classes can declare operator methods with the operator keyword.

class Vector(val x: number, val y: number) {
  operator+(other: Vector): Vector => Vector(x + other.x, y + other.y)
  operator*(scale: number): Vector => Vector(x * scale, y * scale)
}

val v = Vector(1, 2) + Vector(3, 4)  // calls operator+

Computed index access can also be overloaded. operator[] receives the bracket dimensions in order. operator[]= receives the assigned value first, followed by the dimensions, which keeps multidimensional setters consistent.

class Array2<T>(val fallback: T) {
  operator[](x: int, y: int): T => fallback
  operator[]=(value: T, x: int, y: int): void { }
}

val array = Array2<string>("empty")
val cell = array[1, 2]
array[1, 2] = "next"

Variable-dimensional indexers use rest parameters:

class MultiArray<T>(val fallback: T) {
  operator[](...dimensions: int[]): T => fallback
  operator[]=(value: T, ...dimensions: int[]): void { }
}

val item = multi[1, 2, 3]
multi[1, 2, 3] = item

Extension index operators can also target Property<T> values produced by property references:

fun Property<number>.operator[](src: number, dst: number): TweenTarget => TweenTarget(this, src, dst)

tween(view::x[0, 100], time: 1.seconds)

TypeScript has no operator overloading, so equivalent code must use named methods such as get(x, y) and set(value, x, y).

VexaScript also supports the three-way comparison operator <=>. Primitive numbers, big integers, strings, and characters produce a negative value, zero, or a positive value. Classes and extensions may declare operator<=>; when a direct <, <=, >, or >= overload is absent, those comparisons are derived from its result. An operator== overload similarly derives != as its negation. Ordering operators are rejected when neither a primitive ordering nor an applicable direct or spaceship overload is defined.

Class interface delegates

A class can satisfy an interface by forwarding all missing members to a delegate value using by in the heritage clause.

interface Drawable {
  draw(ctx: CanvasCtx): void
  bounds: Rect
}

class Widget(val shape: Drawable) : Drawable by { shape } {
  // draw() and bounds are forwarded to shape automatically
}
// TypeScript equivalent (written by hand)
class Widget implements Drawable {
  constructor(private shape: Drawable) {}
  draw(ctx: CanvasCtx) { this.shape.draw(ctx); }
  get bounds() { return this.shape.bounds; }
}

Extension methods and properties

Methods and read-only properties can be added to existing types without modifying their class.

fun String.shout(): string { return this.toUpperCase() + "!" }
val number.seconds: Duration => Duration(this * 1000)

"hello".shout()   // "HELLO!"
10.seconds        // Duration(10000)

Extension members must be imported before use; they are not automatically in scope across files:

import { shout } from "./stringExtensions"
"hello".shout()

TypeScript has no first-class extension methods; the workaround is prototype augmentation, which is unsafe and not supported in strict mode.

Delegated variables

Variables can delegate reads and writes to an external object using by, inspired by Kotlin property delegates.

fun useState(initial: number) {
  return [() => initial, (v: number) => { initial = v }]
}

var count by useState(0)
count++      // routes through the delegate setter
count += 5

The delegate shape determines the accessor logic:

Delegate type Read Write
[value, setter] first element call second element
[getter, setter] call first call second
Property<T> from expr::field .value getter .value = ... setter
{ value: T } .value .value = ...
() => T call function

TypeScript has no delegated variable syntax.

Property references

expr::field captures a concrete property as Property<T>. At runtime the receiver is evaluated once and the property reference exposes name: string plus a get/set value: T property. It is intentionally different from the :: used inside destructuring patterns: in expression position it creates a bindable property reference, while in object binding patterns it renames a source property.

class View(var x: number)

val view = View(0)
val property = view::x
property.value = 1
var x by property
x = 100 // writes view.x

TypeScript has no direct property-reference expression. The closest equivalent is hand-written getter/setter closures.

Numeric types

VexaScript extends the TypeScript type system with explicit integer types.

Type Description TypeScript equivalent
int 32-bit integer number
number 64-bit floating-point number number
numeric common supertype of int/number/long/bigint
long 64-bit signed integer bigint
bigint arbitrary-precision integer bigint

long literals use the L suffix: 10L, 0xffL. At runtime, long values are lowered to JavaScript bigint with 64-bit wrapping (BigInt.asIntN(64, ...)).

int expressions are wrapped with |0 to keep the values int32.

val count: int = 0
val big: long = 9_223_372_036_854_775_807L
val ratio: number = 3.14

Statements

Range expressions

Ranges are first-class expressions with inclusive (...) and exclusive (..<) variants, inspired by Swift.

0 ... 10   // inclusive: 0 through 10
0 ..< 10   // exclusive: 0 through 9

Range iteration transpiles to a classic index loop:

for (n of 0 ..< 10) console.log(n)
// emits: for (let n = 0; n < 10; n++) console.log(n)

TypeScript has no range syntax.

defer statement

defer expression schedules a cleanup expression to run at the end of the current block, even if the block exits early via return or throw. Inspired by Swift and Go.

val file = open()
defer file.close()
return file.readAll()
// TypeScript equivalent
const file = open();
try {
  return file.readAll();
} finally {
  file.close();
}

for-in / for-of without declaration keyword

In VexaScript mode, loop iterators do not require a declaration keyword.

for (item of items) process(item)
for (key in map) use(key)
// TypeScript
for (const item of items) process(item);
for (const key in map) use(key);

is nominal checks and built-in matcher patterns

The basic value is ClassName form remains VexaScript's shorter spelling of instanceof. Both spellings perform the same nominal runtime check and narrow stable identifiers and member expressions.

if (shape is Circle) {
  shape.radius  // shape is narrowed to Circle here
}

// `instanceof` has the same smart-cast behavior
if (shape instanceof Circle) {
  shape.radius  // shape is narrowed to Circle here too
}
// TypeScript equivalent
if (shape instanceof Circle) {
  shape.radius;
}

Unlike instanceof, is additionally accepts built-in patterns:

if (result is ({ kind: "ok" } and { payload })) {
  val kind: "ok" = result.kind
}

if (temperature is (>= 10 and < 20)) { log("mild") }
if (parts is ["start", ..., "end"]) { log("framed") }
if (path is /^\/users\/[0-9]+$/i) {
  val text: string = path
}

Supported built-ins are primitive type tests, literal equality, object property patterns, exact arrays, one standalone non-binding ... array wildcard, relational patterns, and/or, and regular-expression literals. Primitive patterns (string, number/int, boolean, and bigint/long) lower to typeof rather than instanceof; class names remain nominal. Regex patterns only match strings and accept no flags or the portable g/i flags. Computed keys, object rest, array rest bindings, and custom matcher protocols are not supported. Subjects and recursively inspected values are evaluated once in both JavaScript and C++.

if and abrupt control flow as expressions

In VexaScript, if is an expression. A braced branch evaluates to its final expression statement, an omitted else contributes undefined, and a branch ending in return, throw, break, or continue has type never rather than widening the reachable result.

val label = if (ready) "ready" else {
  prepare()
  "prepared"
}

val value = input ?? return fallback

The abrupt forms return, throw, break, and continue can appear wherever an expression is accepted in .vx files. TypeScript keeps these forms statement-only.

Match expressions

match is an expression lowered to the same typed if/else representation in JavaScript and C++. Arms are ordered and braced bodies evaluate to their final expression without a do keyword.

val label = match {
  ready -> "ready"                 // `->` omits `when`
  when retrying: "retrying"        // `:` requires `when`
  else -> "idle"
}

val result = match (value) {
  { kind: "ok", payload: val payload } -> payload
  [string, val count: number, ...] -> "count=" + count
  when /^error:/i: "error"
  default -> "other"
}

Writing when condition -> body or condition: body is an error. else and default are equivalent. A subject is evaluated once, and subject arms accept the same built-in patterns as is, including >= 10 and < 20, objects, arrays, the standalone ... wildcard, and regex literals. Successful arms preserve their narrowed subject types; later arms retain only exclusions that are logically definite.

Only subject match arms introduce bindings. val name captures with its inferred narrowed type, while val name: Type checks that primitive or class type and captures it. The name is scoped to that arm. Boolean is patterns narrow existing values but do not declare bindings.

Postfix receiver blocks

The postfix form value. { ... } evaluates value once, makes it the implicit receiver inside the block, and returns the same value. It is useful for grouped configuration and mutation without introducing a temporary variable or an apply helper.

val point = Point(10, 20). {
  x *= 2
  y += x / 2
}

VexaScript also supports value?. { ... }. This form evaluates value once, executes the receiver block only when the value is not null or undefined, and returns undefined for a nullish value. The block's receiver is narrowed to the non-nullish type:

canvas.getContext("2d")?. {
  fillStyle = "#f4f8fc"
}

Inside the block, x and y resolve against the Point receiver. The complete expression still evaluates to that same Point instance. JavaScript and native C++ emit the receiver block directly at the use site.

Cascade operator

VexaScript adds the cascade operator ... It evaluates a receiver once, applies each following member operation to that receiver, and returns the receiver.

val badge = Graphics()
  ..point = Vec2(centerX, centerY - 16)
  ..beginFill(0xff6b35)
  ..endFill()
// TypeScript equivalent
const badge = new Graphics();
badge.point = Vec2(centerX, centerY - 16);
badge.beginFill(0xff6b35);
badge.endFill();

TypeScript has no cascade operator.

Embedded XML / JSX

In VexaScript .vx files, JSX is always enabled. A < in expression position starts an XML/JSX element. Consequently, the angle-bracket type cast <Type>value is not available in VexaScript—use value as Type instead.

// VexaScript: JSX always on; use `as` for type casts
val name = maybeString as string
val elem = <div class="greeting">Hello {name}</div>
// TypeScript: JSX opt-in (.tsx); angle-bracket cast available in .ts
const name = <string>maybeString;      // .ts only
const elem = <div className="..."></div>; // .tsx

/// documentation comments

VexaScript uses triple-slash (///) single-line doc comments in addition to /** */ block doc comments. Both are surfaced by the language server in hover and completion tooltips.

/// Returns the distance between two points.
/// [a] and [b] must be in the same coordinate space.
fun distance(a: Point, b: Point): number => Math.hypot(a.x - b.x, a.y - b.y)

Runtime namespaces

VexaScript namespace declarations create actual JavaScript objects at runtime. Exported members become object properties; non-exported members are private to the namespace closure.

namespace MathUtils {
  const PI = 3.14159
  export fun circleArea(r: number): number { return PI * r * r }
}

MathUtils.circleArea(5)

TypeScript namespaces are erased to IIFEs and are primarily a compile-time construct (their emitted objects are accessed through the same IIFE pattern, but VexaScript makes this the first-class runtime model).

Module imports

In addition to .vx, local runtime imports may target .ts, .tsx, .json, and .txt. Appending ?text loads any local file as a string in both JavaScript and native C++ builds:

import declarationSource from "./runtime.d.ts?text"

Text-module imports require exactly one default binding.

Module exports

In VexaScript .vx files, top-level runtime declarations are exported implicitly unless they are marked private, so export is optional for public top-level symbols:

fun greet(name: string): string => `Hello ${name}`
private fun hidden(): string => "secret"
import { greet } from "./helpers"

Explicit export is still supported and remains useful for default exports, re-exports, type-only exports, and teams that prefer the extra clarity at the declaration site.

Comment styles

VexaScript supports three comment styles (TypeScript supports only the first two):

// single-line comment

/// documentation comment (appears in hover/completion)

/* block comment */

Semicolons

Semicolons are optional in VexaScript. Newlines act as statement separators. This is by design—not just ASI—so idiomatic VexaScript code omits semicolons.

let a = 1
let b = 2
a += b