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
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 JSTypes 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
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 1Errors 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
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' ] 5Classes 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:
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:
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
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:
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
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()); // 100Promises 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
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:
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:
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
- Velt for TypeScript developers: every difference, and why.
- The guides: HTTP server, command-line app, async, errors, memory, testing.
- The standard library.