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:
- Valid TypeScript runs as it does in Node. When Velt runs your code, it does what TypeScript would.
- Where JavaScript's behavior is a trap, the compiler stops. Comparing
1 == "1"or forgetting anawaitis a compile error, not a silent bug. The message says exactly what to write instead, and editors offer it as a quick fix. - 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,
sharedstate across threads andextendare 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 ani32exit code) in a file without top-level statements. That is a difference from Node, where amainthat nothing calls never runs. In a file with top-level statements,mainis an ordinary function, as in Node: the usual trailingmain();ormain().catch(…)calls it, and adding a statement to a file that relied onmainrunning by itself stops it from running (the compiler warns thatmainis never called). Top-level statements run in a generatedmain; an imported module's run once when it is evaluated, in Node's order (Scripts). - A module-level
letworks 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 betweenawaits, 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.
anyandunknownexist 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 aTypeErrorwhere Node would carry on with the wrong value (any and unknown). Dynamic JSON is aJsonValue. - 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 fmtprints semicolons.
Numbers
number is a JavaScript number, exactly: a double, with -0, NaN and rounding past 2^53.
const a = 7;
console.log(a / 2, 0.1 + 0.2, -a * 0); // 3.5 0.30000000000000004 -0Numbers 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:
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: " + 5is"Total: 5", ands += nappendsnwritten asString(n)writes it (1e+21,NaN,0for-0). Other values are written asString(x)writes them, as in JS: an array joined with commas, an object by itstoString()([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 (
"😀".lengthis 2), and<orders by code units.s[i]iss.charAt(i), but""past the end where JS givesundefined, andcharCodeAtout of range is-1where JS givesNaN. 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:
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.
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
Mapfor 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.
extendadds methods to any type, includingstring, arrays and your unions. asconverts numbers, brands a value ("u1" as UserId, below) and converts ananyorunknown, checked at run time; there are no unchecked type assertions. Narrow withtypeof,instanceof,==or a discriminant instead.- Enums are numeric or string enums; tagged data is a discriminated union (payload enums and
matchdon't exist). Partial,Required,Readonly,PickandOmitwork on concrete object types, also through generic aliases liketype WithoutChildren<P> = Omit<P, "children">(not yet on a type parameter inside a generic function).Pickrejects a key that isn't a field (Omitwarns).- Intersections
A & Bof 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 ofnever; classes, type parameters and function types (overloads) can't be parts;A & Bdoesn't convert toAwithout a copy ({ ...ab }) (Intersection types). typeof xin types,keyofof object types,ReturnType<F>/Parameters<F>, and index signatures without other members ({ [key: string]: V }, aRecord<string, V>) work as in TypeScript (Type queries).- Conditional types (with
infer), mapped types (withasrenames and+/-modifiers), template literal types andExclude,Extract,NonNullableandAwaitedwork 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
stringhole outside a conditional type'sextends, index signatures next to named members, values in anamespace(namespaces of types work: Namespaces). Interfaces merge, anddeclare global/declare moduleblocks augment them (declaration merging); other declarations don't merge.
Classes
- Single inheritance;
overrideis required on redefined methods (not on the implementation of an abstract method); abstract classes, abstract properties andabstract new () => Twork as in TypeScript; there are noprotectedmembers (privateis private to the declaring class); a constructor can beprivateorprotected, with TypeScript's rules. ES private names (#x,#m(),#x in o) work as in JavaScript: hidden fromconsole.log,JSONandObject.keys, never inherited. staticfields andstatic readonlyconstants exist, andstatic { … }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 usethis. As in TypeScript 4.6+, such statements are allowed also when the class has initialized fields or parameter properties; those are set right aftersuper(...)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 es2022emits them. - A method that is never overridden is called directly; only overridden methods use a vtable.
structdeclares an object type with the same members as a class, built from a literal (no constructor). Planned:structgives way toclassandtype Name = { … }withextend.
Functions
- Function expressions (
function (x) { … }, alsoasyncand generic) work like arrows with the same body; as an object literal's property, one is a method andthisis the object.f.call,f.applyandf.bindwork. A function whosethisis bound at the call, or that readsarguments, declares athis: anyparameter and is then a dynamicFunction(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
returnexpressions (a union when they differ,Promise<T>forasync,voidwithout a value). As in TypeScript, a function whosereturnexpressions depend on the function itself needs an annotation; uses elsewhere in the body don't. Areturn;next toreturn value;is an error rather thanT | undefined: returnnullwith aT | nulltype. - 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>orIteratorObject<T>). Afor...ofover a generator call allocates nothing and runs like a hand-written loop. There is noreturn value,next(value)orthrow(), and afinallyblock in a generator cannotyield, throw, orbreakout of it (Generators). - Async generators (
async function*,async *name()) andfor awaitwork as in JS too (AsyncGenerator<T>); afor awaitover an async generator call allocates nothing (Async generators). Unlike JS, dropping a generator closes it (itsfinallyblocks run), callingnext()orreturn()on a generator from inside its own body stops the program (generator is already running; JS throws a catchableTypeError), 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)givesethe exact union of what thetryblock can throw, neverunknown.- 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 whatparsethrows. 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
assertthrows anAssertionError, 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
awaitsilently 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 beshared(...)or aMutex, and data races are compile errors.- A promise's type carries its error type:
Promise<T, E>. then,catchandfinallywork as in Node, callbacks in Node's order (Async);awaitis the cheaper way to sequence async code.- A value that may or may not be a promise (
View | Promise<View>) is told apart withx instanceof Promise(a thenable check liketypeof 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() => voidcallback (anonClickhandler) starts a promise that runs to completion on its own. new Promise((resolve, reject) => …)andPromise.withResolvers()work as in JS;resolveandrejectmay be kept and called later from any task.setTimeout,setIntervaland theirclearfunctions are globals and take any callback (() => console.log("x"),() => save(doc),async () => { … }), and a pending timer keeps the process alive unless it isunref()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 aMapof promises,arr[i], a class or object field read by several callers, andp.then(f); await pall hold the same promise, and everyawaitof 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 awaitover a channel, a file'slines(), standard input'slines(), a WebSocket or aTicker. 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 defaultand 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 itsindex.vlt,index.tsorindex.tsx.pathsaliases inpackage.vltwork likecompilerOptions.paths. - Modules can be
.tsand.tsxfiles as well as.vlt, so a folder can be shared with a TypeScript project; as in TypeScript, JSX needs.tsx, and"./x.js"namesx.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 / JavaScript | Velt today | Coming |
|---|---|---|
number is always a float | the 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 undefined | both, 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 || 8080 | the same (0, NaN, "", null and undefined are falsy; || and && return an operand) | — |
== coerces | no 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 references | the same: arrays, maps, class instances, object types and closures are references, freed when the last reference goes | — |
| garbage collector | deterministic freeing, no pauses; [Symbol.dispose](), using, await using; WeakRef, WeakMap, WeakSet; cycles are not freed | weak fields |
| structural typing everywhere | object 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 assertions | any 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 anything | Promise<T, E> carries its rejection type | — |
| floating promises lose errors | a 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 turn | prints 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 loop | multi-core runtime; spawn, shared, Mutex; data races are compile errors | — |
| top-level statements | the 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 globals | the same: a module let is one per process, read and updated by every task, without lost updates (module state) | — |
arr.sort() sorts as strings | sort() and toSorted() sort numbers numerically; with a comparator they work like TypeScript | — |
xs.sort(), xs.reverse(), xs.fill(v) return the array | they 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 = 0 | the 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 type | catch changing the value type |
process.stdout.write(s), process.stderr.write(s), process.env.X | the 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.argv | the 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 default | named exports only | — |
for...of over any Iterable; IteratorResult has value: undefined when done | the 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 error | an 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 allocated | an 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 run | dropping 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 TypeError | it panics (generator is already running) | — |
| (no equivalent) | extend adds members to any type, and extend X implements I makes any type implement an interface | module-scoped extensions |
| JSX | server-side rendering through a jsxImportSource provider (velt/jsx) | no client-side DOM; the differences are in the reference |