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