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:

Examples:

let a = 1
var b = 2
val c: number
const d: int = 4

Optional type annotation and initializer

Variable declarations support:

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:

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:

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:

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:

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:

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:

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:

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:

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:

Expressions

Literals

Supported literals:

Unary operators

Supported unary operators:

Binary operators

Supported binary operators:

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:

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:

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:

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:

Examples:

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

Comments

VexaScript supports three comment styles:

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

Builtin types and assignability

Expression typing

Long runtime lowering

Collection typing