Skip to content
The book

Velt for TypeScript developers#

If you write TypeScript, you already write Velt: classes, interfaces, generics, unions and narrowing, discriminated unions, closures, async/await, template literals, destructuring, spread, optional chaining, ??, ES modules, throw/try/catch, using. Velt reads .ts and .tsx files directly (velt run app.ts), and they import .vlt files and the other way round, so an existing project can move over one file at a time.

What you get in return is a native executable: no interpreter or JIT, no garbage collector, and your code running on every core instead of one event loop. This page lists every place where Velt differs from TypeScript, and why.

The differences come from three rules:

  1. Valid TypeScript runs as it does in Node. When Velt runs your code, it does what TypeScript would.
  2. Where JavaScript's behavior is a trap, the compiler stops. Comparing 1 == "1" or forgetting an await is a compile error, not a silent bug. The message says exactly what to write instead, and editors offer it as a quick fix.
  3. Add what makes native code faster. Velt compiles to machine code, not to JavaScript, so it isn't limited to what TypeScript can express. Integer types, typed errors, shared state across threads and extend are there when you want them. Code without them still runs as TypeScript would.

Programs are compiled#

  • A program starts at its root file's top-level statements, as a TS file does, or at function main() / async function main() (which may return an i32 exit code) in a file without top-level statements. That is a difference from Node, where a main that nothing calls never runs. In a file with top-level statements, main is an ordinary function, as in Node: the usual trailing main(); or main().catch(…) calls it, and adding a statement to a file that relied on main running by itself stops it from running (the compiler warns that main is never called). Top-level statements run in a generated main; an imported module's run once when it is evaluated, in Node's order (Scripts).
  • A module-level let works as in Node: one per process, which every task (spawned tasks, HTTP handlers, tasks on other cores) reads and updates, and code that uses it runs as on Node's single thread between awaits, so updates are never lost (module state). A module constant that is not a constant value (new Map(), a call with effects, a function from a call) is evaluated once per process in the same way (module constants from calls).
  • Types are checked once, at compile time, and then gone: the compiled code has no type tags to test. any and unknown exist for values whose type is known only at run time: they hold the value with its type, and converting one back (const n: number = a, u as User) checks it there, stopping the program with a TypeError where Node would carry on with the wrong value (any and unknown). Dynamic JSON is a JsonValue.
  • Semicolons are optional, as in TypeScript: automatic semicolon insertion follows JavaScript's rules, so code formatted without semicolons (Prettier's semi: false, standard style) compiles as it is, and a line starting with (, [, +, - or / continues the line before as it does in Node, with a warning (Comments and semicolons). velt fmt prints semicolons.

Numbers#

number is a JavaScript number, exactly: a double, with -0, NaN and rounding past 2^53.

ts
const a = 7;
console.log(a / 2, 0.1 + 0.2, -a * 0);    // 3.5 0.30000000000000004 -0

Numbers the standard library gives you are numbers too: lengths, indexOf, size, indexes. for (let i = 0; i < xs.length; i++) works, xs.length / 2 is 1.5 for three elements, and xs[i] takes a number (a non-whole index panics).

The difference: integer types (i8 … i64, u8 … u64, isize, usize) exist, and you opt into them by writing them. Declared integers do integer arithmetic: / truncates, values wrap at their width instead of losing precision past 2^53, and integer division by zero panics. Why: the compiler already stores a number as an integer wherever it proves that gives the same result (loop counters, indexes), so integer types are for when you want integer semantics or a fixed width (Numbers).

i8 … i32, u8 … u32 and f32 convert to number implicitly (exactly); the 64-bit types need as number, and a number needs as T to become an integer:

ts
const xs = [1, 2, 3];
console.log(xs.length / 2);       // 1.5
const n: i64 = 7;
console.log(n / 2, n as number / 2, 300 as u8); // 3 3.5 44 (integers wrap)

Booleans#

boolean works as in TypeScript. Velt also accepts the shorter bool for the same type, so (x: bool) => boolean and boolean[] mix freely. Write boolean in code that tsc must also accept; compiler messages and editors print boolean either way (Booleans).

Strings#

  • + with a string and a number or boolean concatenates, as in JS: "Total: " + 5 is "Total: 5", and s += n appends n written as String(n) writes it (1e+21, NaN, 0 for -0). Other values are written as String(x) writes them, as in JS: an array joined with commas, an object by its toString() ([object Object] without one). A function next to a string is a compile error.
  • Lengths and positions count UTF-16 code units, as in JS ("😀".length is 2), and < orders by code units. s[i] is s.charAt(i), but "" past the end where JS gives undefined, and charCodeAt out of range is -1 where JS gives NaN. Text is stored as UTF-8, so files, sockets and HTTP bodies need no conversion; Buffer.byteLength(s) is the UTF-8 size.
  • Strings are immutable values, as in JS, and cheap to copy.

null and undefined#

null and undefined are two values, as in Node: T | undefined prints, compares, tests with typeof and serializes to JSON as in JavaScript (Null and undefined). Optional fields and parameters (a?: T) are T | undefined, as in TypeScript:

ts
class Config {
  host: string = "localhost";
  port?: i64;                       // i64 | undefined, starts as undefined
}

function connect(c: Config, timeout?: i64): string {
  return `${c.host}:${c.port ?? 80} (${timeout ?? 30} s)`;
}

console.log(connect(new Config()));  // localhost:80 (30 s)

The standard library returns undefined where TypeScript's lib.d.ts does, and it prints as undefined: m.get(k) for a key a Map lacks, arr.find(…) without a match, arr.pop() of an empty array, s.at(i) past the end (s[i] and s.charAt(i) there are "").

Truthiness#

The same as in JavaScript: conditions, !, && and || take values of any type, and 0, -0, NaN, "", null and undefined are falsy (also 0 of i64, u8 and the other integer types). || and && return an operand, typed as TypeScript types them, and if (!user) return; is a null check that narrows user.

ts
const count = 0;
const name = "";
const port: number | null = null;
console.log(count || 10, name || "anon", port || 8080, port ?? 8080);   // 10 anon 8080 8080
if (!count && !name) {
  console.log("both falsy");
}

A condition on a number is one comparison in the compiled code, a condition on a string a length check.

Equality#

== never coerces, so it is === (and != is !==) except against null and undefined, where x == null matches either and x === null only null, as in JavaScript. Both sides must have the same type: 1 == "1" is a compile error. Strings compare by content; every object (arrays, class instances, structs, object literals) compares by identity, as in JS. deepEqual(a, b) compares contents.

Objects and types#

  • Object types are exact: an object literal can't have extra fields, and you can't add a property later. Use a Map for dynamic keys. Why: every object has a fixed layout, so a field access is one load.

Interfaces with methods are nominal for classes#

A class implements one by declaring implements. An object literal implements one where it is expected, as in TypeScript (const h: Handler = { name: "x", run(req) { … } }), with methods, function-valued fields and getters (Object literals implementing interfaces). An interface with only fields is an object type, like type User = { … }, so model interfaces work as in TypeScript: literals satisfy them, JSON.parse<User> reads them, and as a bound (<T extends HasId>) any type with the fields fits. Unlike TypeScript, a class instance is not a User value (it is shared by reference; build a User from its fields). Recursive models (next?: Node) work, and JSON.stringify of one that contains itself fails as in JavaScript.

  • Interfaces may have default method bodies. extend adds methods to any type, including string, arrays and your unions.
  • as converts numbers, brands a value ("u1" as UserId, below) and converts an any or unknown, checked at run time; there are no unchecked type assertions. Narrow with typeof, instanceof, == or a discriminant instead.
  • Enums are numeric or string enums; tagged data is a discriminated union (payload enums and match don't exist).
  • Partial, Required, Readonly, Pick and Omit work on concrete object types, also through generic aliases like type WithoutChildren<P> = Omit<P, "children"> (not yet on a type parameter inside a generic function). Pick rejects a key that isn't a field (Omit warns).
  • Intersections A & B of object types work as in TypeScript, unions distributing over them, and so do indexed access types (User["name"]) and branded primitives (type UserId = string & { __brand: "UserId" }, zero-cost). Parts with no value in common are an error instead of never; classes, type parameters and function types (overloads) can't be parts; A & B doesn't convert to A without a copy ({ ...ab }) (Intersection types).
  • typeof x in types, keyof of object types, ReturnType<F>/Parameters<F>, and index signatures without other members ({ [key: string]: V }, a Record<string, V>) work as in TypeScript (Type queries).
  • Conditional types (with infer), mapped types (with as renames and +/- modifiers), template literal types and Exclude, Extract, NonNullable and Awaited work as in TypeScript where their type arguments are known: they are evaluated at compile time into ordinary types (Conditional, mapped and template literal types). In a generic body, a value of such a type can be stored, passed and returned, but not yet read or converted.
  • Not available: a template literal type with a string hole outside a conditional type's extends, index signatures next to named members, values in a namespace (namespaces of types work: Namespaces). Interfaces merge, and declare global / declare module blocks augment them (declaration merging); other declarations don't merge.

Classes#

  • Single inheritance; override is required on redefined methods (not on the implementation of an abstract method); abstract classes, abstract properties and abstract new () => T work as in TypeScript; there are no protected members (private is private to the declaring class); a constructor can be private or protected, with TypeScript's rules. ES private names (#x, #m(), #x in o) work as in JavaScript: hidden from console.log, JSON and Object.keys, never inherited.
  • static fields and static readonly constants exist, and static { … } blocks run where the class is declared, as in JavaScript.
  • Constructors follow TypeScript's super(...) rules: a derived constructor calls it exactly once (also when the base has no constructor), and statements before it cannot use this. As in TypeScript 4.6+, such statements are allowed also when the class has initialized fields or parameter properties; those are set right after super(...) returns.
  • Field initializers and constructors run in JavaScript's order (base initializers, base constructor, derived initializers, derived constructor), and parameter properties come first in the field order, as tsc --target es2022 emits them.
  • A method that is never overridden is called directly; only overridden methods use a vtable.
  • struct declares an object type with the same members as a class, built from a literal (no constructor). Planned: struct gives way to class and type Name = { … } with extend.

Functions#

  • Function expressions (function (x) { … }, also async and generic) work like arrows with the same body; as an object literal's property, one is a method and this is the object. f.call, f.apply and f.bind work. A function whose this is bound at the call, or that reads arguments, declares a this: any parameter and is then a dynamic Function (Function expressions).
  • Overloads in TypeScript's form (bodiless signatures before one implementation) work for functions and methods (Overloads). Optional and default parameters work, on arrows too; rest parameters (...xs: T[]) take spread arguments (f(...xs)) at their position. Callbacks may take fewer parameters than they are passed (xs.map((x) => …) gets (x, i)).
  • Parameter types are required. As in TypeScript, an omitted return type is inferred from the return expressions (a union when they differ, Promise<T> for async, void without a value). As in TypeScript, a function whose return expressions depend on the function itself needs an annotation; uses elsewhere in the body don't. A return; next to return value; is an error rather than T | undefined: return null with a T | null type.
  • Generics are compiled per instantiation (monomorphized), so generic code is as fast as hand-written code. Bounds are interfaces.
  • Generators (function*, *name() methods, yield, yield*) work as in JS, lazily, with their return type written (Generator<T>, Iterable<T>, Iterator<T>, IterableIterator<T> or IteratorObject<T>). A for...of over a generator call allocates nothing and runs like a hand-written loop. There is no return value, next(value) or throw(), and a finally block in a generator cannot yield, throw, or break out of it (Generators).
  • Async generators (async function*, async *name()) and for await work as in JS too (AsyncGenerator<T>); a for await over an async generator call allocates nothing (Async generators). Unlike JS, dropping a generator closes it (its finally blocks run), calling next() or return() on a generator from inside its own body stops the program (generator is already running; JS throws a catchable TypeError), and a generator cannot be passed to another task.

Errors#

throw, try, catch and finally look the same, but errors are typed and checked:

  • catch (e) gives e the exact union of what the try block can throw, never unknown.
  • Functions may declare throws A | B; the compiler infers it otherwise, and checks it when written. Callbacks propagate errors: xs.map((x) => parse(x)) throws what parse throws.
  • attempt(() => f()) turns a throwing call into a value: T | E.
  • Index out of bounds and integer division by zero are panics: bugs that stop the program (exit code 101) and can't be caught. A failed assert throws an AssertionError, as in Node.

Why: no unwinding (each throwing call is a cheap check), and no error type is ever a surprise (Error handling).

Async#

  • Promises start when created, like JS, and a directly awaited call costs nothing.
  • In a function, a promise that is neither awaited nor spawned is a compile error ("floating promise"). Why: a forgotten await silently loses errors in JS. Among the root file's top-level statements, render(); starts the promise as in Node, and the program waits for it before exiting.
  • spawn(f()) runs a task on another core; the runtime is multi-threaded. Data shared between tasks must be shared(...) or a Mutex, and data races are compile errors.
  • A promise's type carries its error type: Promise<T, E>.
  • then, catch and finally work as in Node, callbacks in Node's order (Async); await is the cheaper way to sequence async code.
  • A value that may or may not be a promise (View | Promise<View>) is told apart with x instanceof Promise (a thenable check like typeof x.then === "function" doesn't apply). An async arrow passed where such a union is returned returns the promise, as in TS, and one passed as a () => void callback (an onClick handler) starts a promise that runs to completion on its own.
  • new Promise((resolve, reject) => …) and Promise.withResolvers() work as in JS; resolve and reject may be kept and called later from any task. setTimeout, setInterval and their clear functions are globals and take any callback (() => console.log("x"), () => save(doc), async () => { … }), and a pending timer keeps the process alive unless it is unref()ed, as in Node (velt/time). To wait, await sleep(ms).
  • A promise is a shared value, as in TypeScript: const q = p, m.get(k) on a Map of promises, arr[i], a class or object field read by several callers, and p.then(f); await p all hold the same promise, and every await of it gets the result (the same object for an object result). A holder that goes to another task (spawn, a channel, a timer callback) is awaited there through a copy of the result (Promises).
  • Std streams are async iterables: for await over a channel, a file's lines(), standard input's lines(), a WebSocket or a Ticker. Unlike a Node stream, whose iterator destroys the stream when the loop is left early, leaving the loop keeps the source open (a channel may have other receivers): close it yourself (Std sources).

Memory#

There is no garbage collector, so no GC pauses and no heap tuning: memory is freed as soon as its last reference goes away, and [Symbol.dispose]() runs at that moment. Memory is managed automatically: you write no annotations, and the compiler works out when each object is freed. Objects are shared references exactly like in JS (const b = a; b.push(1) changes a), and .clone() is an explicit deep copy. Values with a single owner cost nothing extra; only types the program actually shares get a reference count. Reference cycles are not freed; WeakRef, WeakMap and WeakSet work as in JS and let go of their targets at once (planned: weak fields) (Memory without a garbage collector).

Modules#

  • Named exports only: export default and default imports are errors with a fix.
  • Standard library modules are imported as velt/<module>: import { readFile } from "velt/fs".
  • Relative imports drop the extension: import { x } from "./util". A folder is a module through its index.vlt, index.ts or index.tsx. paths aliases in package.vlt work like compilerOptions.paths.
  • Modules can be .ts and .tsx files as well as .vlt, so a folder can be shared with a TypeScript project; as in TypeScript, JSX needs .tsx, and "./x.js" names x.ts (TypeScript files).

Not supported#

var in nested blocks and loops, eval, prototypes, delete (other than on a Record), with, decorators, symbols made at run time as object keys (a symbol key must be a module constant: Symbols), BigInt values (the type bigint has none yet, Types; use velt/math), Unicode identifiers. x! is checked (a null panics) where TypeScript trusts it, and as const keeps the value as it is. Date follows JS (months 0-11, local-time getters); its toString() has no time zone name and its toLocale… methods always format as en-US. JSX is supported for server-side rendering (TSX, velt/jsx).

Quick reference#

TypeScript / JavaScriptVelt todayComing
number is always a floatthe same; the compiler stores it as an integer where that gives the same result; i64, u8, … are opt-in—
"5" + 1 === "51"the same: numbers and booleans as String(x) writes them, an array joined with commas, an object by its toString(); a function next to a string is a compile error—
null and undefinedboth, as in Node, and a?: T is T | undefined, and the standard library gives undefined where lib.d.ts does (Map.get, find, pop)—
if (count), port || 8080the same (0, NaN, "", null and undefined are falsy; || and && return an operand)—
== coercesno coercion: == is === except that x == null also matches undefined, and the reverse (objects by identity, deepEqual for contents); both sides have the same type—
objects are shared referencesthe same: arrays, maps, class instances, object types and closures are references, freed when the last reference goes—
garbage collectordeterministic freeing, no pauses; [Symbol.dispose](), using, await using; WeakRef, WeakMap, WeakSet; cycles are not freedweak fields
structural typing everywhereobject types and interfaces with only fields structural but exact; a class implements an interface with methods by declaring implements, an object literal by fitting it—
any, unknown, type assertionsany and unknown hold a value with its type, checked when converted back (a TypeError instead of a wrong value); as converts numbers and dynamic values, no unchecked assertions; JsonValue for dynamic JSON—
catch (e: unknown)e is the exact union of what the try can throw—
Promise<T> rejects with anythingPromise<T, E> carries its rejection type—
floating promises lose errorsa floating promise in a function is a compile error; among the root file's top-level statements, f(); starts it as in Node and the program waits for it—
an unhandled rejection prints the source line and a stack, and is reported when no handler is attached by the end of the turnprints Uncaught <Type>: <message> at file:line:col and exits with code 1, reported only once nothing can await the promise any more: const h = spawn(f()); await sleep(100); await h; handles h's rejection—
new Promise(...)the same; await of one abandoned unsettled is reported—
single-threaded event loopmulti-core runtime; spawn, shared, Mutex; data races are compile errors—
top-level statementsthe same: the root file's run as the program, with top-level await; an imported module's run once when it is evaluated, in Node's order (no top-level await there)—
mutable module globalsthe same: a module let is one per process, read and updated by every task, without lost updates (module state)—
arr.sort() sorts as stringssort() and toSorted() sort numbers numerically; with a comparator they work like TypeScript—
xs.sort(), xs.reverse(), xs.fill(v) return the arraythey work in place and return nothing (returning the array would make it reference counted), but a chained access reads the changed array as in TypeScript (xs.sort().join(",")); other uses of the result are an error with the fix; xs.toSorted() and xs.toReversed() return sorted / reversed copies, as in ES2023—
xs.length = 0the same: setting length drops the elements past it; a larger length panics (arrays have no holes)—
xs.splice(i, n, a, b), xs.push(a, b)the same—
p.then(f).catch(g)the same; catch(g) gives the promise's value typecatch changing the value type
process.stdout.write(s), process.stderr.write(s), process.env.Xthe same on the builtin process (process.env.X is string | undefined; instead of assigning to it or delete, call setEnv and removeEnv from velt/process)—
Object.keys(process.env), { ...process.env }envAll() from velt/process: a Record<string, string> snapshot, in the same order—
process.argvthe same layout, [runtime, script, ...args]: the script is the source file under velt run, the executable for a built program; each read is a new array, so changing it in place is an error (copy it first)—
a.localeCompare(b, locale, options) (the host's locale by default)a.localeCompare(b): the CLDR root collation, like new Intl.Collator("und").compare(a, b); no locales—
export defaultnamed exports only—
for...of over any Iterable; IteratorResult has value: undefined when donethe same protocol ([Symbol.iterator](), next(), return() on early exit, returning { done: true }); a done result has no value (unnarrowed, r.value is T | null, so g().next().value works); Iterator<T, E> carries the error type next() throws; for await over AsyncIterables, and over arrays of promises—
generators: function*, yield, yield*, Generator<T, TReturn, TNext>the same, lazy, Generator<T, E> (E: what the body throws); TS's Generator<T, void, unknown> spelling means Generator<T>, and a real TReturn is an error; no return value, next(value) (so yield has no value) or throw(), each an error that says so; a for...of over a call allocates nothing; async generators (AsyncGenerator<T, E>) likewise, and yield p there awaits a promise p as in JS—
arrays, strings, Maps and Sets are Iterable; a[Symbol.iterator]()the same: they convert to Iterable<T> values and satisfy Iterable<T> bounds (sum(xs: Iterable<number>) takes [1, 2, 3]); x[Symbol.iterator]() returns an Iterator<T>, live for arrays as in JS; map and set iterators see the entries as of the call (JS's are live), and m.keys() / values() / entries() are arrays; for...of over a map (a variable, field, getter, element or call result, or its keys(), values(), entries()) and forEach are live, as in JS—
IterableIterator<T>, IteratorObject<T>, AsyncIterableIterator<T>, IteratorResult<T, TReturn>the same interfaces ([Symbol.iterator]() returns Iterator<T>: no covariant returns); generators implement them, and such a value converts to an Iterable<T>. IteratorResult<T, void> is IteratorResult<T>; a real TReturn is an erroran IterableIterator<T> value converting to Iterator<T> (interface values don't convert to the interfaces they extend)
spread, Array.from, array destructuring, new Map / new Set of any iterable (strings, Maps, Sets, generators)the same: an iterable is iterated where it stands, destructuring takes only the values it needs and then closes the iterator, and over a direct generator call only the result is allocatedan array-typed spread source is evaluated before the literal's other elements ([f(), ...g()] with g(): T[] calls g first; the elements are still read in place); a nested pattern takes its values after the outer one has taken all of its own
function expressions: function* () {}, async function* () {}, function () {}generator expressions work, sharing the variables they use as in JS (no recursion through the expression's name); other function expressions are arrows—
object literals with methods: { *[Symbol.iterator]() { ... } }, { m() { ... } }the same: this is the object; a literal implements the interface it is assigned to; one whose one member is an iterator method is an Iterable<T>—
an unreachable generator is never closed: its finally blocks never rundropping the last reference to a suspended generator closes it: its finally blocks run and its using values are disposed then (there is no garbage collector to wait for)—
next() on a generator from inside its own body throws a catchable TypeErrorit panics (generator is already running)—
(no equivalent)extend adds members to any type, and extend X implements I makes any type implement an interfacemodule-scoped extensions
JSXserver-side rendering through a jsxImportSource provider (velt/jsx)no client-side DOM; the differences are in the reference

Edit this page on GitHub