Skip to content
The book

A tour of Velt#

Velt reads like TypeScript and runs like Rust: it compiles to native code, has no garbage collector, and runs your async code on every core of the machine. This tour shows the language in one sitting. A program is a file of statements that run from the top, as a Node script does: no main, no boilerplate. Every example compiles; the full rules are in the Reference.

Values and types#

ts
const a = 7;             // a number, exactly as in JS (stored as an integer where that's exact)
const x: f64 = 1.5;      // number is f64
console.log(a / 2, a + x);  // 3.5 8.5, like JS
const n: i64 = 7;        // a declared integer type: integer math, opt-in
console.log(n / 2, Math.trunc(a / 2));   // 3 3: integer division
let small: u8 = 250;
small += 10;             // 4: integer math wraps
console.log(n as number / 2, 300 as u8); // 3.5 44: 64-bit integers convert only with `as`
const s = `a=${a} x=${x}`;               // template literals; "a=" + a is "a=7", like JS

Types are required on function signatures and inferred everywhere else. Conditions work as in JavaScript (0, NaN, "", null and undefined are falsy; if (!user) return; is a null check that narrows user), and null and undefined are two values, as in Node. See Types and Variables and conditions.

Functions and errors#

ts
function fib(n: i64): i64 {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

class ParseError extends Error {}
class Overflow extends Error {}

function digit(s: string): i64 throws ParseError {   // `throws` is optional (inferred)
  if (s == "1") return 1;
  throw new ParseError(`bad digit: ${s}`);   // compiled to a result return: no unwinding
}

function sum(xs: string[]): i64 {
  const total = xs.map((x) => digit(x)).reduce((a, b) => a + b, 0);
  if (total > 9) throw new Overflow("too big");
  return total;                             // throws ParseError | Overflow (inferred)
}

try {
  console.log(fib(10), sum(["1", "1"]), sum(["1", "x"]));
} catch (e) {                               // e: ParseError | Overflow
  if (e instanceof ParseError) console.log("parse:", e.message);   // parse: bad digit: x
  else console.log("overflow");
}
const r = attempt(() => digit("1"));        // i64 | ParseError: the error as a value
console.log(r instanceof ParseError ? "no digit" : `digit ${r}`);  // digit 1

Errors are typed: catch (e) gets the exact union of what the try block can throw, calls propagate errors automatically (also out of callbacks like map), and a throws clause is checked when you write one (Error handling).

Classes, interfaces and structs#

ts
struct Point {                      // a value type: copied, stored inline
  x: f64;
  y: f64;
  len(): f64 { return Math.sqrt(this.x * this.x + this.y * this.y); }
}

interface Named {
  name(): string;
  greet(): string { return `hi ${this.name()}`; }   // a default method
}

class Animal implements Named {     // a heap object, shared by reference
  readonly species: string;
  private id: i64 = 0;
  constructor(species: string) { this.species = species; }
  name(): string { return this.species; }
  speak(): string { return "..."; }
}

class Dog extends Animal {
  override speak(): string { return "woof"; }        // virtual only because it's overridden
}

function greetAll<T extends Named>(xs: T[]): string[] {   // monomorphized: static dispatch
  return xs.map((x) => x.greet());
}

const pets: Named[] = [new Dog("rex"), new Animal("cat")];  // interface values: dynamic dispatch
for (const p of pets) console.log(p.greet());                 // hi rex, hi cat
console.log(greetAll([new Dog("fido")]), Point { x: 3.0, y: 4.0 }.len());   // [ 'hi fido' ] 5

Classes support private, readonly, getters and setters, static members, constructor parameter properties, abstract classes, single extends and any number of implements. Generics are monomorphized (Classes).

extend adds methods to existing types: builtins, your own, and function types too:

ts
extend string {
  shout(): string { return this.toUpperCase() + "!"; }
}

extend<R> (() => R) {                       // every function without parameters
  twice(): R[] { return [this(), this()]; }
}

let n = 0;
const next = () => ++n;
console.log("hi".shout(), next.twice());   // HI! [ 1, 2 ]

Discriminated unions and switch#

Tagged data is a discriminated union (object types sharing a literal-typed field), and switch takes it apart, narrowing the value in each case:

ts
type Shape = { kind: "circle"; r: f64 } | { kind: "rect"; w: f64; h: f64 };

function area(s: Shape): f64 {
  switch (s.kind) {                          // a jump table on the tag
    case "circle":
      return Math.PI * s.r * s.r;            // `s` is the circle here
    case "rect":
      return s.w * s.h;
  }                                          // every kind handled: no `return` needed after
}

const shapes: Shape[] = [{ kind: "circle", r: 1.0 }, { kind: "rect", w: 2.0, h: 3.0 }];
console.log(shapes.map((s) => area(s)));   // [ 3.141592653589793, 6 ]

The union has a Rust enum's layout: kind is the tag, not a stored string. In a function that returns a value, a switch without default must handle every kind. switch also works on numbers, strings, enums, typeof x and unions of literals, with JavaScript's fallthrough and break (Control flow).

Arrays, maps and closures#

ts
const xs = [1, 2, 3, 4];
let total = 0;
xs.forEach((x) => { total += x; });          // closures passed directly can update captures
const squares = xs.map((x) => x * x).filter((x) => x > 4);

const counts = new Map<string, i64>();        // insertion-ordered, like JS
counts.set("a", (counts.get("a") ?? 0) + 1);
for (const [word, n] of counts) console.log(word, n);

const [first, second] = [10, 20];
const point = { x: 1.0, y: 2.0 };
const merged = { ...point, z: 3.0 };
console.log(total, squares, first + second, merged);   // 10 [ 9, 16 ] 30 { x: 1, y: 2, z: 3 }

Memory in one minute#

There is no garbage collector. Numbers and strings are values you can copy freely. Other objects (arrays, class instances, maps) are references, as in JS, and are freed the moment their last reference goes. Passing an object to a function passes the same object, as in JS, and costs nothing:

ts
class User {
  name: string = "ann";
}

function log(u: User) { console.log(u.name); }
function save(u: User) { console.log("saved", u.name); }

const user = new User();
log(user);            // the same object: `user` stays usable
save(user);

You never annotate memory: the compiler works out which functions modify their arguments and when each object can be freed. Assigning an object to a second variable refers to the same object, as in JS; .clone() makes a deep copy. Memory without a garbage collector explains the model.

Async and I/O#

ts
import { readFile, writeFile } from "velt/fs";

async function fetchUser(id: i64): Promise<string> {
  await sleep(10);
  return `user ${id}`;
}

await writeFile("hello.txt", "hi");              // top-level await, as in a Node module
console.log(await readFile("hello.txt"));        // hi

const a = fetchUser(1);                          // starts now, like JS
const b = fetchUser(2);                          // runs concurrently with a
console.log(await a, await b);                   // user 1 user 2, after ~10 ms in total

const counter = shared(0);
const tasks: Promise<void>[] = [];
for (let i = 0; i < 100; i++) {
  tasks.push(spawn(async () => { counter.add(1); })); // on any core
}
await Promise.all(tasks);
console.log(counter.get());                      // 100

Promises start when they are created, like in JS, and a directly awaited call costs nothing. Inside a function, forgetting an await is a compile error ("floating promise"); among the top-level statements, render(); starts the promise as Node does and the program waits for it. Spawned tasks run on every core. A module-level let is one per process, as in Node, and any task may update it; a function's local variables they can't modify, so tasks share those through shared(...) or shared(new Mutex(...)) (Async and concurrency).

JSON#

ts
class User {
  name: string = "";
  age: i64 = 0;
  tags: string[] = [];
}

const u = JSON.parse<User>(`{"name":"ada","age":36,"tags":["admin"]}`);
console.log(u.name, u.age + 1);             // ada 37
console.log(JSON.stringify(u));             // {"name":"ada","age":36,"tags":["admin"]}
try {
  JSON.parse<User>(`{"name":"ada","age":"old"}`);
} catch (e) {
  console.log(e.message);                   // expected i64 at $.age
}

JSON.parse<T> checks the text against T and builds a T; JSON.stringify writes any value. There is no reflection at run time: a source generator writes the code for each type at compile time, so it runs as fast as hand-written code. Libraries can declare generated functions of their own (Source generators). For other JSON work, see velt/encoding/json.

An HTTP server#

The HTTP server is the http-server package, written in Velt. velt new todo --template api makes a package that depends on it:

src/main.vlt
import { serve } from "http-server";

let hits = 0;
const server = await serve({ port: 8080 }, (req: Request): Response => {
  hits++;                                   // module state, as in Node
  return Response.json({ path: new URL(req.url).pathname, hits });
});
console.log(`listening on http://localhost:${server.port}`);
// like Node, a listening server keeps the program running (until `server.close()`)

Handlers take the web-standard Request and return a Response, the same classes fetch uses, and run concurrently on every core (Building an HTTP server).

Modules#

Modules work like TypeScript's ES modules, with named exports only:

ts
import * as path from "velt/path";            // namespace import: path.join, path.basename, …
import { gcd as greatestDivisor } from "velt/math";

export function describe(file: string): string {
  return `${path.basename(file)} (${greatestDivisor(12, 18)})`;
}

console.log(describe("/tmp/a.txt"));        // a.txt (6)

The standard library is imported as velt/<area> or velt/<area>/<module> (velt/fs, velt/net/http, velt/encoding/json); the global functions and types, such as console, fetch, Map and JSON, need no import (Standard library). A folder is a module through its index.vlt, import type imports types only, and paths in package.vlt replaces long ../../ chains (Modules and packages).

Where next#

Edit this page on GitHub