This document shows the whole syntax of the language. There is another section with only the differences on top of TypeScript.
VexaScript Supported Syntax
This document tracks the language syntax currently supported by VexaScript.
Variables
Declaration keywords
VexaScript supports variable declaration statements using:
letvarvalconst
Examples:
let a = 1
var b = 2
val c: number
const d: int = 4
Optional type annotation and initializer
Variable declarations support:
- optional type annotation (
: TypeName) - optional initializer (
= expression) - multiple declarators separated by commas
Examples:
let name: UserName = currentUser
let counter: int
let enabled
val a = 10 * 2, myboolean = true
Delegated variables
Variables may use a Kotlin-like by delegate instead of an initializer. The delegate customizes reads and writes of the declared identifier. The delegate shape is selected from its compile-time type:
[value, setter]reads from the first tuple element and writes with the second element as a(newValue) => voidsetter.[getter, setter]calls a zero-argument getter for reads and calls the setter for writes.{ value: T }reads and writes the delegate object'svalueproperty.- A zero-argument function delegate is read by calling the function.
Assignments, compound assignments, and update expressions use the delegate setter/value path, so x = x + 1, x += 1, x++, ++x, x--, and --x all route through the custom accessor.
fun useState(value: number) {
return [value, (newValue: number) => { value = newValue }]
}
var count by useState(0)
count = count + 1
count += 1
count++
Destructuring declarations
Variable declarations support nested object and array binding patterns. Object bindings may use shorthand names, property aliases, inline type annotations, defaults, and rest bindings. In VexaScript object destructuring, : introduces an inline type annotation and :: renames a source property to a local binding. Array bindings may use holes, inline type annotations, defaults, nested patterns, and rest bindings. When the initializer has a tuple type, each introduced array binding receives the corresponding tuple element type unless an inline binding annotation overrides it. TypeScript mode keeps TypeScript destructuring rules: object binding : renames properties and destructuring patterns do not accept inline binding type annotations.
let { id, name :: displayName, nested :: { value = 1 }, ...rest } = source
let { name : string, title :: displayTitle : string } = props
const [first : string, , third = 3, ...tail] = values
const [result, setResult] = useState(0) // result: int, setResult: (newValue: int) => void
Functions
Functions can be declared with fun or TypeScript-style function. Both forms support async and generator modifiers when emitted to JavaScript:
async function load(id: string): Promise<Response> {
return await fetch(id)
}
function* ids() {
yield 1
}
In async functions, return expressions are checked against the inner Promise<T> value type, so both return 10 and return Promise.resolve(10) are valid for Promise<int>. await expr evaluates to T when expr has type Promise<T>; otherwise await preserves the original type. When no return type is annotated, the inferred return type is Promise<T>. If an async function has an explicit return type annotation, it must be Promise<...>.
await is only allowed at the top level (module/global scope) and inside async or sync functions. Using await inside a normal (non-async/sync) function or a normal generator is a semantic error (AWAIT_OUTSIDE_ASYNC).
async functions behave exactly like TypeScript: Promise-typed expressions are not implicitly awaited, so you write await explicitly. Pervasive auto-await is exclusive to sync functions (described below), which model Kotlin-style suspend functions.
sync functions (implicit await)
The sync modifier declares a function that behaves like async internally (it is emitted as a JavaScript async function and may use await), but with two ergonomic differences:
- The return type is written without the
Promise<...>wrapper.sync fun load(): Responseis internally an async function returningPromise<Response>; from the outside (and from other functions) the call is observed asPromise<Response>, so it participates in auto-await just like any other Promise. - Inside a
syncfunction body, any subexpression whose type isPromise<T>is automatically awaited wherever it is used as a value, and its observed type becomesT. This applies everywhere — expression statements, variable initializers, assignment right-hand sides, call arguments, operands, array/object elements, and member receivers. This also works for Promise-returning functions imported from other files, including functions whosePromisereturn type is inferred from their body rather than annotated — the imported value's type is resolved from its declaring file, so calling it inside asyncfunction auto-awaits just like a local call.
sync fun fetchValue(): int {
return 1
}
sync fun main(): int {
let x = fetchValue() // let x = await fetchValue(); -> x: int
fetchValue() // await fetchValue();
use(fetchValue(), fetchValue() + 1) // use(await fetchValue(), (await fetchValue()) + 1);
return x + 10
}
When the receiver of a member access is a Promise, it is awaited before the member is accessed, so fetchBox().value() becomes (await fetchBox()).value(). The exceptions, where the Promise is kept and not awaited, are:
- Accessing a Promise method (
.then,.catch,.finally). returnexpressions (a returned Promise is flattened by the surrounding async function).- Bare references to a local variable or parameter. Auto-await only happens at the point a Promise is produced (a call, a member call, ...), not when an already-stored Promise value is read. Once a Promise is held in a variable it keeps its
Promise<T>type until it is awaited explicitly.
sync fun demo(): void {
let stored = go fetchValue() // stored: Promise<int> (go opts out)
let alias = stored // alias: Promise<int> (local reference, not awaited)
let inline = fetchValue() // inline: int (awaited at the call site)
}
sync is also valid on methods, arrow functions, and function expressions (class C { sync m(): int { ... } }, sync () => { ... }, sync function () { ... }).
The go contextual operator
To opt out of the implicit await and obtain the underlying Promise<T>, prefix the expression with the contextual go operator. go expr is never awaited and keeps the Promise<T> type, in any position:
sync fun main(): void {
let pending: Promise<int> = go fetchValue() // let pending = fetchValue();
go fetchValue() // fire-and-forget: fetchValue();
use(go fetchValue()) // pass the Promise along: use(fetchValue());
go fetchValue().then(handle) // .then also keeps the Promise
}
go is contextual: it only acts as the no-await operator when an operand follows on the same line. Otherwise it remains a normal identifier, so existing code using go as a variable or function name keeps working.
Because go only has meaning where implicit auto-await happens, it is only allowed inside sync functions. Using go inside a normal or async function, or at the top level, is a semantic error (GO_OUTSIDE_SYNC).
A TypeScript this parameter may appear first in a function-like parameter list for type analysis. It is erased during JavaScript emission:
function bind(this: Loader, id: string): string {
return id
}
Type assertions with as Type, const assertions, and non-null assertions are parsed and erased during JavaScript emission. Const assertions keep the analyzed expression type without attempting to resolve const as a named type. Non-null assertions remove null and undefined from the analyzed expression type without changing runtime output. The angle-bracket cast <Type>value is TypeScript-only because VexaScript reserves < for embedded XML/JSX:
let name = value as string
let precise = [1, 2] as const
let definitelyName = maybeName!
Function declarations
VexaScript supports function declarations with both keywords:
funfunction
Examples:
fun add(a, b) {
return a + b
}
function sum(a, b) {
return a + b
}
Runtime namespaces and modules
Runtime namespace and identifier-named module declarations group values behind a JavaScript object. Exported variables, functions, classes, enums, and nested namespaces become object members; declarations without export remain private to the generated namespace closure. Semantic analysis validates exported member access, and member completion offers the exported namespace surface.
namespace Tools {
const prefix = "v"
export const version = 1
export function label(): string { return prefix + version }
}
console.log(Tools.label())
Runtime namespaces are lowered to the conventional JavaScript namespace object plus IIFE pattern. String-literal module names remain restricted to ambient external modules such as declare module "pixi.js".
Ambient declarations
VexaScript supports ambient declarations with declare for functions, classes (including abstract class), variables (var / let / const / val), type aliases, interfaces, enums, namespaces, and modules. Ambient declarations can also be wrapped in TypeScript-style export declare. In typescript parser mode, ambient external modules may use a string-literal name such as declare module "pixi.js". Ambient namespace and module bodies preserve supported declarations in the AST, participate in scoped semantic analysis and semantic highlighting, and are erased during JavaScript emission. Unsupported declaration-file members are recovered as opaque regions so large third-party .d.ts files remain parseable.
Example:
declare function moment(inp?: moment.MomentInput, strict?: boolean): moment.Moment;
declare type MomentFactory = (input: moment.MomentInput) => moment.Moment;
export declare abstract class Clock {}
declare class Console {
log(a: number)
}
declare var console: Console
declare namespace Company.Tools {
export interface Config {
name: string
}
export const version: string
}
declare module "pixi.js" {
export = PIXI;
}
A cached ECMAScript ambient runtime is loaded automatically for every analysis session, so common globals such as Array, Map, Set, Math, JSON, console, Date, RegExp, Promise, Error, and typed arrays such as Uint8Array are available without imports. The declarations live in compiler/runtime/es2025.d.ts and are copied to dist/es2025.d.ts by the build so language-server declaration navigation can open the declaration file next to the bundled executable.
Parameters
Function parameters support:
- plain parameters (
a) - optional marker (
a?) - optional type annotation (
a: Int) - optional default value (
a: Int = demo) - rest parameters (
...items: Item[]), which must be last
Examples:
fun test(a, v, c?, d: Int = demo) {
return d
}
fun collect(label: string, ...values: int[]) {
return values
}
Parameters also support object and array binding patterns, including nesting, property aliases with ::, inline binding type annotations with :, defaults, holes, and rest bindings. The introduced binding names are available throughout the function body, and the patterns are preserved in emitted JavaScript with type-only annotations erased:
function unpack(
{ id, nested :: { value = 1 }, label : string, name :: displayName : string, ...metadata },
[first : string, , ...tail] = values
) {
return value
}
Return type annotation
Functions support optional return type annotation:
fun demo(a, b): Int {
return a + b
}
When the body is just a single returned expression, declarations and class methods can also use => shorthand:
fun demo(a, b): Int => a + b
class Point(val x: number, val y: number) {
operator*(other: Point): Point => Point(x * other.x, y * other.y)
}
Function overloads
Multiple top-level functions may share the same source name when their parameter type signatures differ. During JavaScript emission, overloaded implementations are currently name-mangled with their parameter types, and typed calls are rewritten to the matching emitted name:
function describe(value: int): string { return "int" }
function describe(value: string): string { return value }
describe(1) // emits as describe$$int(1)
describe("one") // emits as describe$$string("one")
Signature-only overload declarations may be written without a body and are omitted from JavaScript output.
Inline JavaScript implementations
A bodyless function may use @JsInline to provide a trusted JavaScript template that is inserted at each direct call site. Parameter identifiers in the template are replaced with the emitted call arguments. When an argument is omitted, its declared default value is used; otherwise it is replaced with undefined.
@JsInline("if (!cond) throw new Error(message)")
fun assert(cond: boolean, message: string = "assert failed")
assert(value > 0)
The annotated declaration itself is omitted from JavaScript output. Templates are raw JavaScript and are responsible for being valid in every context where the function is called.
Custom JavaScript names
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 the declaration and each use site:
annotation Benchmark
@Benchmark
fun measure() {}
The @JsName("...") annotation overrides the final JavaScript name of a declaration. It can be applied to functions, classes, enums, interfaces and variables. The source name is still used inside VexaScript, but JavaScript emission uses the supplied name for both the declaration and every reference to it:
@JsName("rgba")
class Color(val r: int, val g: int, val b: int, val a: int)
@JsName("clamp01")
function clampUnit(value: number): number { return Math.max(0, Math.min(1, value)) }
val white = Color(255, 255, 255, 255) // emits as new rgba(255, 255, 255, 255)
clampUnit(2) // emits as clamp01(2)
Member property names are not affected by @JsName; only the renamed declaration and references to it are rewritten. Annotations stack, so @JsName and @JsInline may be combined on the same declaration.
Test files
The CLI test command discovers files ending in .test.vx. Each test file receives inline test(call) and assert(cond, message = "assert failed") helpers without imports:
test(() => {
assert(2 * 3 == 6)
})
test invokes its callback, and assert throws an Error when its condition is false. The helpers are implemented with @JsInline, so test execution does not require additional runtime files.
Implicit member access
Inside a class method or field initializer, class members can be referenced without writing this.. Parameters and local variables still shadow members with the same name. JavaScript emission qualifies each resolved implicit member with this.:
class Counter(val value: int) {
increment(amount: int): int {
return value + amount // emits as: return this.value + amount
}
}
The same implicit receiver lookup is available inside extension methods and extension properties. Extension members are emitted as standalone receiver-mangled functions whose first parameter is the receiver ($this), so both implicit members and this resolve to that generated receiver parameter:
fun Counter.doubled(): int { return value + value }
val Counter.next => increment(1)
Operator overloads
Classes can declare binary operator overload methods with operator followed by the operator token. Mangled runtime names use $ for operator names and $$ before the parameter-type signature. The runtime lowering rewrites matching binary expressions to method calls:
class Point(val x: number, val y: number) {
operator+(other: Point): Point {
return new Point(this.x + other.x, this.y + other.y)
}
}
let c = a + b // emits as a.operator$plus$$Point(b) when a is Point
Binary operators may also be declared as extension methods by placing the receiver type before .operator. Unlike class operators (which stay prototype methods), extension members — operators, named methods and properties — are emitted as standalone functions whose mangled runtime name begins with the receiver type and whose first argument is the receiver. They participate in the same type-directed lowering:
fun Point.operator+(other: Point): Point {
return new Point(this.x + other.x, this.y + other.y)
}
let c = a + b // emits as Point$$operator$plus$$Point(a, b)
Named extension methods follow the same scheme, so a call lowers to a plain function call with the receiver passed first:
fun Counter.doubled(): int { return value + value }
let n = counter.doubled() // emits as Counter$$doubled$$void(counter)
Extension properties
A read-only extension property is declared with a receiver type before the property name, an optional : Type annotation, and => before its value expression. Inside the expression, this is the receiver value:
class Duration(val milliseconds: number)
export val number.milliseconds => Duration(this)
val number.seconds: Duration => Duration(this * 1000)
val duration = 10.milliseconds
Extension properties are opt-in across files. A consumer imports the source-level property name, and access without that import is reported as a missing member:
import { milliseconds } from "./duration"
val duration = 10.milliseconds
At JavaScript runtime, declarations and imports are mangled with the receiver type. For example, number.milliseconds is exported as number$$milliseconds, and 10.milliseconds is lowered to number$$milliseconds(10).
Generic extension methods and properties
Extension methods and extension properties can be generic. Type parameters are written before the receiver type, and the receiver itself may carry type arguments — including built-in collection types such as Array<T>:
fun <T> Array<T>.second(): T { return this[1] }
val <T> Array<T>.doubledLength => length * 2
let xs = [10, 20, 30]
let value = xs.second() // 20
let total = xs.doubledLength // 6
let empty = [].doubledLength // 0
The receiver's base type name drives runtime mangling (the type arguments are erased), so Array<T> extensions are emitted as Array$$... functions and resolve for any array value, including array literals like []. Inside the body, implicit member access resolves against the receiver type's members; for Array<T> receivers this includes built-in members such as length.
Generic function declarations
Function declarations support generic type parameters, and explicit generic type arguments on calls specialize parameter and return types:
fun identity<T>(value: T): T {
return value
}
let name: string = identity<string>("Ada")
Function expressions and arrow functions
VexaScript parser supports TypeScript-style function expressions and arrow functions in expression position.
Examples:
[1, 2, 3, 4].map(a => 10)
[1, 2, 3, 4].map((it) => 10)
[1, 2, 3, 4].map(function(it: number) { return 10 })
Tail lambdas
VexaScript also supports Kotlin/Swift-style tail lambdas after call expressions and brace lambdas inside call argument lists. Inside an argument list, { name } is context-sensitive: it is a one-parameter lambda with the implicit it parameter when the corresponding parameter type is a function, and a shorthand object literal when the parameter is not a function. The explicit { arg1, arg2 -> ... } form is always a lambda.
The body after -> may be a single expression or a sequence of statements. When it contains more than one statement, the lambda has a block body, and a final expression statement is emitted as an implicit return:
[1, 2, 3].map {
const doubled = it * 2
doubled + 1 // implicit return
}
new Promise({ resolve, reject ->
setTimeout(resolve, time.ms)
setTimeout(reject, 1000)
})
Examples:
[1, 2, 3, 4].map { it }
[1, 2, 3, 4].map() { it }
[1, 2, 3, 4].map { a, b, c -> a + b + c }
[1, 2, 3, 4].map { a: number, b: number, c: number -> a + b + c }
transform({ it })
transform({ value -> value + 1 })
consumeOptions({ options })
Imports
VexaScript supports ES module imports at top level, including named imports, aliases, default imports, namespace imports, side-effect imports, and type-only imports:
import { Point } from "./a"
import { Point, Vector as Vec } from "./geometry/types"
import React from "react"
import React, { useState as useLocalState } from "react"
import * as fs from "fs"
import "./setup"
import type { Shape } from "./types"
Type-only imports participate in semantic analysis as bindings but are omitted from emitted JavaScript output.
Relative imports can target local .ts/.tsx files as well as .vx files. Extensionless resolution checks the direct path, then .vx, .ts, .tsx, .json, and .txt. During vexa run and CLI bundling, local TypeScript modules are parsed in TypeScript mode, type-checked with their exported declarations available to the importing VexaScript file, transpiled to JavaScript, and inlined into the same executable module. This supports TypeScript runtime declarations such as classes, functions, variables, enums, destructuring, arrow functions, and async functions; type-only constructs such as interfaces and type aliases remain analysis-only and are erased from emitted JavaScript. Local JSON and text assets can be imported as default imports; JSON imports are parsed and inlined as JavaScript values, while text imports are inlined as strings.
import { Color, Person, describePerson } from "./helpers"
import config from "./config.json"
import readme from "./readme.txt"
const ada = Person("Ada", 36)
console.log(describePerson(ada))
console.log(Color.Green)
console.log(config.title)
console.log(readme.trim())
Extension operator overloads declared in another file (for example fun Point.operator+) can be imported by their operator name so the operator resolves across files:
import { Point, operator+ } from "./geometry"
val sum = Point(1, 2) + Point(3, 4)
An operator+ binding is not a real runtime export: it is installed on the receiver's prototype as a side effect of loading the module, so it is dropped from the emitted named bindings while the module load is preserved. When a binary operator is reported as undefined and a matching overload exists in another file, a quick fix offers to add the operator import.
Exports
VexaScript supports ES module exports for declarations, named export lists, re-exports, default exports, and type-only export lists:
export const answer: number = 42
export function add(a: number, b: number): number {
return a + b
}
export async fun loadAnswer(): Promise<number> {
return 42
}
export sync fun loadCachedAnswer(): number {
return loadAnswer()
}
export class Point
export default Point
export { Point as RenamedPoint }
export { Shape } from "./shape"
export * from "./math"
export type Name = string
export type { Shape } from "./types"
export as namespace MyLib
Type-only exports and exported type aliases/interfaces participate in analysis but are omitted from emitted JavaScript output. export as namespace is supported for TypeScript-style global UMD declarations; it participates in parsing and editor highlighting and is omitted from JavaScript output.
Classes
Class methods support async and generator modifiers:
class Store {
async save() {
return await persist(this)
}
*values() {
yield 1
}
}
Class declarations
VexaScript supports class declarations:
class Demo {
}
In VexaScript mode, class braces are optional for empty class declarations:
class Point
Class declarations also support:
- generic type parameters and constraints (
class Box<T extends Entity>) extendsclausesimplementsclauses (type-only, omitted in emitted JavaScript)abstractclasses for abstract member declarations
Examples:
class Base<T> {
}
interface Entity {
id: string
}
class Box<T extends Entity> extends Base<T> {
}
Get and set accessors
Class bodies support TypeScript-style property accessors. Getter accessors must not declare parameters, and setter accessors must declare exactly one parameter. Accessor type annotations participate in member type analysis as property types. Getters also support a shorthand form that omits get and the empty parameter list when the body is a single returned expression.
Example:
class Box {
get value(): string {
return this.raw
}
set value(next: string) {
this.raw = next
}
}
class Rect {
area: number => this.width * this.height
}
TypeScript constructor parameter properties
TypeScript-style constructors can promote parameters to instance properties by adding an access modifier (public, private, or protected) and/or readonly. Parameter properties participate in type analysis, access-control and readonly diagnostics, member completion/navigation, and are initialized automatically during JavaScript emission.
class User {
constructor(public readonly id: string, private age: int = 0) {
}
birthday() {
this.age = this.age + 1
}
}
Modifiers are only valid on parameters of a class constructor; ordinary function and method parameters cannot use them.
Class interface delegates
A class can satisfy an interface by delegating missing interface members to another value with by in its heritage clause. The delegate can be written as an expression, or as the common single-shorthand brace form used to expose an existing instance member. The delegated expression must resolve to a value assignable to the interface.
For each interface property or method that the class does not declare itself, VexaScript synthesizes a forwarding member at JavaScript emission time. Explicit class members win over delegated members with the same name.
interface Shape {
area: number
fill(color: string): string
}
class MyDemo(val shape: Shape) : Shape by { shape } {
}
This is equivalent to writing the forwarding members by hand:
class MyDemo(val shape: Shape) : Shape {
area => shape.area
fill(color: string) {
return shape.fill(color)
}
}
Optional primary constructor
Class declarations support an optional primary constructor parameter list after the class name.
Each primary constructor parameter currently supports:
- optional declaration kind (
let,var,val,const, defaults tovalwhen omitted) - parameter name
- optional type annotation (
: TypeName) - optional default value (
= expression)
Example:
class Point(val x: number, val y: number) {
}
class Point(x: number, y: number) {
}
This form also allows omitting braces in VexaScript mode:
class Point(val x: number, val y: number)
Class fields
Class fields support:
- optional declaration kind keywords (
var,let,val,const) before the member name; the legacy keyword-less form still works - field name
- optional marker (
field?: TypeName) - definite assignment assertion marker (
field!: TypeName) - optional type annotation (
: TypeName) - optional initializer (
= expression) - access modifiers (
public,private,protected) readonlyfields (assignable from constructors, diagnosed on later writes);valandconstare the preferred immutable spellings inside class bodiesstaticfields
Examples:
class Demo {
var a = 10
let b: Int = 20
c: Int
public val id?: string
private static var count: Int = 0
service!: Service
}
Class methods and constructor
Class members can be methods, including constructor. Methods support the explicit fun keyword as the preferred spelling inside class bodies, while the older keyword-less form remains valid. Methods also support access modifiers (public, private, protected), static, and abstract signatures inside abstract classes. Derived class methods can use super calls and super.member access to reference inherited base-class behavior:
class Demo {
constructor() {
}
fun demo() {
}
}
Method signatures support the same parameter syntax as function declarations.
Class fields and methods also support the override modifier when redefining members from a base class:
class Base {
value: string
}
class Child extends Base {
override value: string
}
Interfaces
VexaScript supports interface declarations, including generic parameters and extends. Interface members can also use the preferred explicit member keywords (val / var / let / const for properties and fun for methods), while the older TypeScript-style member form still works:
interface PairStore<K, V> extends Iterable<K> {
val keys: K[]
val values: V[]
fun get(key: K): V
}
interface declarations are type-only and are omitted from emitted JavaScript output.
Enums
VexaScript supports TypeScript-style enum and const enum declarations with numeric auto-increment members, numeric initializers, and string initializers. Enum declarations create a named semantic type, enum member access is checked as a known member, and non-ambient enums emit JavaScript runtime enum objects. Ambient declare enum declarations participate in analysis but are omitted from emitted JavaScript.
Examples:
enum Direction {
Up,
Down = 4,
Left,
Right = "right"
}
const enum Status {
Ready = 1,
Done
}
let direction: Direction = Direction.Up
Type aliases
VexaScript supports type aliases for naming another supported type annotation form. Aliases may be generic and can be used anywhere a type annotation is accepted:
type Text = string
type Boxed<T> = Box<T>
type Status = "ready" | "done"
type Pair = [string, int]
type UserKey = keyof User
type UserName = User["name"]
type NameCopy = typeof currentUser.name
let name: Text = "Ada"
let boxed: Boxed<Text> = new Box<string>()
type declarations are type-only and are omitted from emitted JavaScript output. Mapped and conditional types are preserved structurally by the parser; semantic analysis resolves the portions it understands and otherwise treats them conservatively as unknown.
Type annotation forms
Supported type annotation forms in declarations/members:
- plain type names (
Point,number,K) - primitive/builtin type names (
int,number,string,boolean,bigint,long,void,null,undefined,any,unknown,never,object,symbol) - generic type references (
Map<K, V>) - array suffixes (
K[],Map<K, V>[]) - union types (
string | number) - intersection types (
Named & Serializable) - function types (
(value: int) => string, including optional and rest parameters) - object type literals (
{ x: int; label?: string }) - literal types (
"ready",404,true) - tuple types (
[string, int]) keyoftype operators (keyof User)typeoftype queries over values and dotted members (typeof config,typeof user.name)- indexed access types (
User["name"],Tuple[0],User[keyof User]) - mapped types (
{ [K in keyof T]?: T[K] }) - conditional types (
T extends U ? X : Y) - inferred conditional-type variables (
T extends (infer U)[] ? U : T)
Expressions
Literals
Supported literals:
- integer literals (
10) - decimal/scientific number literals (
10.573,10e-3) - numeric separators (
1_000,10.5_25,1e1_0) - non-decimal integer literals (
0xff,0b1010,0o755) - bigint literals (
10n,0xfn) - long literals (
10L,0xffL) - string literals (
"hello",'hello') - template string literals with interpolation (
`hello ${name}`) - regular expression literals (
/abc+/gi) - boolean literals (
true,false) - nullish literals (
null,undefined) - array literals (
[1, 2, 3]) with spread elements ([0, ...values]) and sparse holes ([1, , 3]) - object literals (
{a: 1, b: 2}), including shorthand properties, spread properties, computed keys, string/number literal keys, and method properties ({ add(a, b) { return a + b } })
Unary operators
Supported unary operators:
- unary plus (
+x) - unary minus (
-x) - logical not (
!x) - bitwise not (
~x) typeof xvoid xdelete xawait xyield xandyield* iterablein generator functions- prefix increment (
++x) - prefix decrement (
--x) - postfix increment (
x++) - postfix decrement (
x--)
Binary operators
Supported binary operators:
- range:
...(inclusive),..<(exclusive) - exponentiation:
** - multiplicative:
*,/,% - additive:
+,- - shift:
<<,>>,>>> - relational:
<,>,<=,>=,in,is,instanceof - equality:
==,!=,===,!== - bitwise:
&,^,| - logical:
&&,||,??
Assignment operators
Supported assignment operators:
=+=,-=,*=,/=,%=<<=,>>=,>>>=&=,|=&&=,||=??=
Conditional and comma operators
VexaScript supports ternary conditional expressions:
condition ? whenTrue : whenFalse
Comma expressions are supported at the lowest expression precedence. They evaluate operands left-to-right and use the final operand type during semantic analysis:
let value: string = (log(), "ok")
for (let i = 0; i < 10; i++, total += i) {
work
}
Comma-delimited lists such as call arguments and array elements remain separate syntax, so fn(a, b) is parsed as two arguments. Use parentheses for a comma expression argument, for example fn((a, b)).
Regular expression literals
VexaScript supports JavaScript-style regular expression literals in expression positions. They are emitted unchanged and are inferred semantically as RegExp named values, which can be supplied by ambient declarations or host TypeScript definitions.
declare class RegExp {}
let matcher: RegExp = /a[0-9]+/gi
Type assertions
VexaScript supports TypeScript-style value as TypeName assertions in expressions. Assertions are erased during JavaScript emission and the semantic checker treats the expression as the asserted target type. The checker reports an unsafe assertion when neither the source type nor target type is assignable to the other.
let value: unknown = readValue()
let name: string = value as string
The angle-bracket cast form <TypeName>value is not available in VexaScript, because < always begins an embedded XML/JSX element (see Embedded XML / JSX). The angle-bracket cast remains available only when the parser runs in TypeScript mode with JSX disabled (the default for .d.ts-style consumption).
Embedded XML / JSX
VexaScript supports embedding XML directly in expressions, exactly like JSX/TSX. There is a single VexaScript mode and it always enables this: a < in expression position that is followed by a tag name (or > for a fragment) starts an element instead of a less-than operator.
val greeting = <div class="greeting" id={userId}>Hello {name}!</div>
val list = <ul>{items.map((item) => <li key={item.id}>{item.name}</li>)}</ul>
val fragment = <><Header/><Body {...props}/></>
Supported features mirror JSX/TSX:
- Intrinsic elements with a lowercase tag name (
<div>) and component/dotted tags (<Foo>,<Foo.Bar>). - Self-closing elements (
<input/>), fragments (<>...</>), and nested elements. - Attributes: string values (
class="x"), expression containers (value={expr}), boolean shorthand (disabled), and spread attributes ({...props}). - Children: text (with JSX whitespace normalization), expression containers (
{expr}), and nested elements.
Embedded XML is transpiled with the classic React runtime: elements become React.createElement(...) calls and fragments use React.Fragment. Intrinsic lowercase tags are emitted as string literals; component and dotted tags are emitted as references.
// <div class="greeting">Hi {name}</div>
React.createElement("div", { class: "greeting" }, "Hi ", name)
The element factory and fragment factory are configurable. They default to the classic React runtime (React.createElement / React.Fragment) but can be overridden through the emitter/transpile options jsxFactory and jsxFragmentFactory, the vexa build flags --jsx-factory and --jsx-fragment-factory, or a project-level tsconfig.json (compilerOptions.jsxFactory and compilerOptions.jsxFragmentFactory). A tsconfig.json with compilerOptions.jsxImportSource set to "preact" is mapped to Preact's classic factories (h and Fragment) while VexaScript emits classic JSX factory calls.
{
"compilerOptions": {
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment"
}
}
// with --jsx-factory h --jsx-fragment-factory Fragment or the tsconfig.json above
// <><span/></>
h(Fragment, null, h("span", null))
In TypeScript mode, embedded XML is opt-in through the jsx parser option; enabling it disables the angle-bracket cast (matching .tsx semantics).
Range expressions
Range expressions are supported with start ... end (inclusive) and start ..< end (exclusive), matching Swift syntax:
0 ... 10 // inclusive: 0, 1, 2, ..., 10
0 ..< 10 // exclusive: 0, 1, 2, ..., 9
... is end-inclusive, so 0 ... 10 iterates/generates values from 0 to 10. ..< is end-exclusive, so 0 ..< 10 iterates/generates values from 0 to 9.
Useful in for loops:
for (n of 0 ..< 10) {
console.log(n)
}
// equivalent to
for (let n = 0; n < 10; n++) {
console.log(n)
}
Member access
Supported member access forms:
- dot access:
obj.prop - safe access:
obj?.prop - non-null asserted access:
obj!.prop - computed access:
obj[index] - optional computed access:
obj?.[index]
Optional member and computed access include undefined in their inferred result type. Non-null asserted access removes null and undefined from the receiver type before resolving the member and is erased to normal dot access during JavaScript emission.
Array literals
Array literals preserve TypeScript/JavaScript sparse holes during emission and runtime execution. A hole contributes undefined to semantic element inference, so [1, , 3] is compatible with an (int | undefined)[] expectation and emits as a sparse JavaScript array. TypeScript-style tuple type annotations use square brackets, including labeled tuple elements such as [value: T, setter: (newValue: T) => void].
let values: (int | undefined)[] = [1, , 3]
let state: [value: int, setter: (newValue: int) => void] = [0, (newValue: int) => {}]
Object literals
Object literals support explicit properties, shorthand properties, spread properties, computed keys, string literal keys, number literal keys, optional trailing commas, and later properties override earlier spread properties during semantic shape inference:
let name = "Ada"
let base = { id: 1, name: "Base" }
let user = { name, ...base, name: "Grace", [dynamicKey]: value, "display name": name, 1: value, }
Object spread operands are semantically checked as object-compatible values. Known object, class, and interface member types are merged into the inferred object shape.
Function calls
Function call expressions are supported, including calls chained from member access, optional calls, spread arguments, and optional generic type arguments:
hello.world[0].test(arg1, arg2)
maybeCallback?.(arg1, arg2)
collect("label", ...values)
factory<string, number>(arg1, arg2)
Named arguments
Call and new arguments may be passed by parameter name using name: value.
Named arguments can be written in any order and freely mixed with leading
positional arguments; the compiler reorders them into the callee's positional
parameter order when emitting JavaScript. Editor completion suggests the
available parameter names (for example url:) inside an argument list.
fun connect(host: string, port: number) {}
connect(port: 8080, host: "localhost") // reordered to connect("localhost", 8080)
connect("localhost", port: 8080) // positional + named
class Point(val x: number, val y: number)
let point = Point(y: 2, x: 1) // reordered to new Point(1, 2)
Class instantiation and new expressions
A declared class can be called directly to instantiate it. ClassName(arguments) is equivalent to new ClassName(arguments). Constructor-only ECMAScript globals from ambient declarations use the same class-call style, including calls with generic type arguments such as Map<string, number>(entries):
class Point(val x: int, val y: int)
let point = Point(1, 2)
let scores = Map<string, number>([["Ada", 3]])
TypeScript-style new expressions are also supported, including constructor arguments, generic type arguments, and member-based constructor targets:
new instance()
new instance
new Map<string, string>()
new hello.world[0].test(arg1, arg2)
Statements and control flow
Smart casts
Within if and else branches, stable identifier types are narrowed by is, instanceof, and range-membership (in) checks. The false branch excludes the checked member from union types, and negated checks reverse the branch narrowing. is is emitted as JavaScript instanceof.
if (value is Cat) {
value.meow()
} else {
value.bark()
}
if (value in 0 ... 10) {
let numberValue: int = value
}
Block statements
Blocks are supported with braces:
{
let a = 1
let b = 2
}
While
while (condition) {
doWork
}
With
VexaScript supports TypeScript-style with statements. The object expression and body are visited during semantic analysis, and the statement is preserved during JavaScript emission.
with (scope) {
use(value)
}
Switch statements
VexaScript supports switch statements with case and default clauses. Semantic analysis reports an error when a switch body contains more than one default clause. It also reports non-empty cases that can fall through to a following case; add an explicit break, return, throw, or continue when a case should stop before the next label. LSP diagnostics expose dedicated codes for duplicate defaults and switch fallthrough.
switch (value) {
case 1:
break
default:
break
}
Do-while
do {
work
} while (condition)
For
VexaScript supports TypeScript-style for loops:
for (let i = 0; i < 10; i += 1) {
work
}
Each clause is optional:
for (;;) {
break
}
VexaScript also supports for-in without declaration keyword:
for (value in iterable) {
work
}
VexaScript also supports for-of without declaration keyword:
for (value of iterable) {
work
}
Range iteration syntax is supported and transpiles to a classic index loop:
for (a of 0 ... 10) console.log(a)
When running in typescript parser mode, for-in and for-of with declaration iterators are supported:
for (let value in iterable) {
use(value);
}
for (const value of iterable) {
use(value);
}
If / else
VexaScript supports TypeScript-style if statements with optional else:
if (condition) {
doWork
} else {
fallback
}
Switch / case / default
VexaScript supports TypeScript-style switch statements with case and optional default. Non-empty cases must end control flow explicitly before the next label:
switch (value) {
case 1:
return 1
default:
return 0
}
Return, continue, break, debugger, and empty statements
Supported statements:
returnreturn expressionthrow expressioncontinuecontinue labelfor labeled loop targetsbreakbreak labelfor active statement labelsdebugger- empty statements (
;), including as loop bodies such aswhile (condition);
Statement labels
Statements can be labeled. Labeled break targets may reference any active label, while labeled continue targets must reference a label whose statement is a loop.
outer: while (running) {
if (done) break outer
continue outer
}
blockLabel: {
break blockLabel
}
Try / catch / finally
VexaScript supports TypeScript-style exception handling:
try {
riskyWork()
} catch (err) {
throw err
} finally {
cleanup()
}
Defer
defer expression schedules cleanup for the end of the current block. It wraps everything that remains in that block in a try / finally, so the deferred expression still runs when the block returns early or throws.
val file = open()
defer file.close()
return file.read()
Equivalent to:
val file = open()
try {
return file.read()
} finally {
file.close()
}
Program structure
Statements can be separated by:
- semicolons
- newlines
Examples:
let a = 1
let b = 2;
a += b
Comments
VexaScript supports three comment styles:
- single-line comments with
// - documentation comments with
/// - block comments with
/* ... */
Examples:
let a = 1 // single-line comment
/// searches [sub] in [str]
/// and returns its index or -1
fun find(str: string, sub: string): int { }
/*
multi-line
block comment
*/
let b = 2
TypeScript parser mode
When the parser runs in typescript mode directly or because a local .ts/.tsx module is imported into a VexaScript module graph, it supports ES module imports (import { ... } from "...", default imports, namespace imports, side-effect imports, and import type), ambient declarations (declare function, declare type, declare abstract class, declare interface, declare enum, declare var/let/const, and export declare ...), TypeScript-style for statements (including for-in / for-of with declaration iterators), if / else statements, switch / case / default, and throw / try / catch / finally.
Example:
import { Point } from "./a";
declare function moment(inp?: moment.MomentInput, strict?: boolean): moment.Moment;
declare class Console {
log(...a: number[])
}
declare var console: Console
for (let i = 0; i < 10; i += 1) {
const current = i;
}
if (current > 0) {
current--;
}
switch (current) {
case 1:
break;
default:
break;
}
try {
risky(current);
} catch (err) {
throw err;
} finally {
cleanup();
}
Semantic Rules (Current)
Name resolution
- Global scope allows forward references to declarations.
- Local/function scopes require a symbol to be declared before use.
Builtin types and assignability
- Builtin types:
int,number,numeric,string,boolean,bigint,long,void,null,undefined,any,unknown,never,object,symbol. intis assignable tonumber.longis assignable tobigint.numericis the common supertype of the integer family (int/number) and the big-integer family (long/bigint);int,number,long, andbigintare all assignable tonumeric. The numeric tower isnumeric -> number -> intandnumeric -> bigint -> long.anyis assignable to and from all types.neveris assignable to all types.- All types are assignable to
unknown. - Object literals, named/class/interface shapes, arrays, and functions are assignable to
object. - Literal types are assignable to their matching primitive type, but primitive values are not assignable to a specific literal type unless contextual checking proves the literal value matches.
- A value is assignable to a union if it is assignable to at least one union member.
- A value is assignable to an intersection if it satisfies every intersection member.
- Tuple values are assignable to tuple targets with the same length and compatible element types, and tuple values are assignable to arrays when each tuple element is assignable to the array element type.
- Function type annotations are checked structurally by parameter and return types.
- Object type literal annotations are checked structurally by their property names and types; optional properties include
undefinedin their semantic type. - Other assignability checks are strict by type identity in the current version.
Expression typing
- Integer literals have type
int. - Decimal/scientific numeric literals have type
number. - BigInt literals have type
bigint. - Long literals have type
long. - String literals have type
string. - Boolean literals have type
boolean. nullhas typenull.undefinedhas typeundefined.- Regular expression literals have the named type
RegExp. +,-,*,/,%, shifts and bitwise operators onintoperands inferint.+with at least onestringoperand infersstring.- Comparisons/equality/logical operators infer
boolean. start ... endinfersrange<int>and is end-inclusive;start ..< endinfersrange<int>and is end-exclusive.
Long runtime lowering
longliterals are lowered to JavaScriptbigintliterals (10L->10n).- Long arithmetic/bitwise expression results are wrapped as
BigInt.asIntN(64, expression)to keep 64-bit signed behavior.
Collection typing
- Array literals infer an element type from their items. Sparse holes contribute
undefinedto element inference. - When an array literal is checked against an expected array type, that element type is used as context for nested generic calls.
- When an array literal is checked against an expected tuple type, each tuple element type is used as context for the corresponding array element.
- Array literals returned from functions infer tuple return types, so generic helpers such as
useState<T>(value: T) { return [value, (newValue: T) => {}] }preserve each destructured element type at call sites. - Homogeneous arrays infer typed arrays, for example
int[]. - Mixed element types unify to their common supertype. Members of the numeric tower unify to
numeric, so[10, 10L](anintand along) infersnumeric[]. - Mixed incompatible arrays (with no common supertype, for example
[10, "string"]) fall back toany[]. - An array variable whose element type is still unknown (for example
const array: unknown[] = []orlet xs = []) evolves its element type from the firstpush/unshiftmutation, soarray.push(10)refines the inferred type ofarraytoint[]. - Object literals checked against an expected object, class, or interface type use matching property types as context for nested generic calls.