Skip to content
Language reference

Modules and packages#

Exports#

ts
export function area(r: f64): f64 {
  return Math.PI * r * r;
}
export const LIMIT = 10;

function clamp(x: f64): f64 {
  return Math.min(x, LIMIT);
}
export { clamp as limit };              // names declared (or imported) in the file

Each file is a module. export marks functions, classes, structs, interfaces, types, enums and constants as importable; everything else is private to the file (`x` is not exported). export { a, b as c }; exports names under the listed names.

  • Named exports only: export default is an error whose fix is a named export (export function name, or export { name };), and so is a default import (import x from).
  • extend blocks cannot be exported: they apply wherever their module is loaded.

Imports#

ts
import { a, b as c } from "…";     // binds exported names
import * as ns from "…";           // binds the module as a namespace
import type { T } from "…";        // names for type positions only
import { type T, f } from "…";     // the same, for one name
import "…";                        // loads a module without binding names

A namespace reaches the module's exports through ns: ns.f(), ns.LIMIT, new ns.Class(), ns.Enum.Member, ns.Class.make(), and ns.Type in type positions. ns itself is not a value; a local variable named ns shadows it.

A name imported with type is for type positions only; using one as a value is an error. Types are compile-time only either way, so this changes no code, as in TypeScript.

A dynamic import(…) is an error: every module is loaded while compiling, so import it with a static import instead.

import.meta#

ts
import.meta.dirname    // the source file's directory
import.meta.filename   // its absolute path
import.meta.main       // true in the program's root module, false in the others
import.meta.resolve(s) // the specifier s, resolved against the module's URL
import.meta.url        // the file's file: URL

import.meta describes the module, as in Node, with Node's keys in Node's order. The root module is the file velt run or velt build is given. The values are fixed while compiling, so they name the source file, not the built program, wherever it runs.

ts
const here = import.meta.url;
console.log(here.startsWith("file:///")); // true

console.log(import.meta) prints it as Node does, and JSON.stringify leaves resolve out:

Text
[Object: null prototype] { dirname: …, resolve: [Function: resolve], … }

import.meta.resolve#

resolve resolves as Node does, without checking that the file exists:

SpecifierResult
An absolute URLas the URL parser normalizes it: HTTPS://Example.com/a is https://example.com/a
A relative or absolute path (./data.json, ../a?x=1#h, /etc/hosts)resolved against the module's URL; an absolute path keeps a Windows drive letter
One of Node's built-in modulesnode:<name>: fs is node:fs, _http_agent is node:_http_agent
A package namethrows an Error with Node's message, Cannot find package 'x' imported from <file>

Packages are compiled into the program rather than looked up while it runs, so try / catch around import.meta.resolve("optional-dep") takes the catch. Its type is therefore (specifier: string) => string throws Error.

Stored as a function type written without throws (const r: (s: string) => string = import.meta.resolve, or import.meta as a { resolve: (s: string) => string }), that type throws its errors, as every such type throws what is stored into it (Dynamic calls).

import.meta as a value#

  • Passed where an object type with some of its fields is expected (function f(p: { readonly url: string }), f(import.meta)), it becomes a copy of those fields, as for any object type of another layout: assigning one of its fields is an error, and it is not === to import.meta.
  • Used as a value of its own (const m = import.meta, return import.meta), it is the same object. Its fields are read-only, also through such a value (m.url = "x" is an error, as import.meta.url = "x" is).

Scripts: top-level statements#

A program starts like a TS or JS file in Node, at the top-level statements of its root file (the file velt run or velt build is given): the statements outside declarations run in order in a generated main (async when one of them awaits). A root file with function main() (or async function main()) and no top-level statements runs main instead (below). A top-level const or let moves into the generated main with the statements, unless a function, class, struct, extend block or module constant refers to it; then it stays at module level (module state). So does one that a closure the statements make refers to (an arrow, a function expression, a method of an object literal, a function declared in a block) when the code assigns it or it holds a new object (new Map(), [], not a new Promise(…)): as in Node, the closure then sees the module's variable itself, with what a statement assigns after the closure was made and what the closure assigns or changes, also from a request handler or a task on another thread (let hits = 0; and a serve handler that runs hits++). A closure gets a copy of one that nothing assigns, which is the same value.

Not yet module state: a destructuring declaration, a let without an initializer, one whose initializer awaits (const server = await serve(…)), an object literal a closure changes (const stats = { n: 0 }), or a new object declared with a type that checks narrow (const m: object = new Map()): those stay locals of main, which a closure captures as a copy. So does one that module state can't hold: a promise (let p = Promise.resolve(1) that a closure assigns), or one a Mutex with callback uses (module state), with the variables below it whose initializers read it (let ok = p !== undefined;). A with callback that calls a function value (v += add(), a parameter, a loop variable, the locked value) may run any closure the script makes, so then every variable a closure in the script uses stays a local of main. That one callback turns module state off for the whole script: a task or a request handler that assigns such a variable (spawn(async () => { hits++; })) is then rejected.

ts
const GREETING = "hello";               // a function uses it: a module constant

function greet(name: string): string {
  return `${GREETING} ${name}`;
}

const names = ["ann", "bob"];           // only statements use it: a local of `main`
names.push("cy");
for (const n of names) {
  console.log(greet(n));
}

let calls = 0;                          // a closure uses it: module state
const shout = (n: string) => {
  calls++;
  return greet(n).toUpperCase();
};
console.log(names.map(shout).join(" "), calls); // HELLO ANN HELLO BOB HELLO CY 3

The statements and the declarations between them run in source order, as in Node: a module constant, a module let or a class's static fields are evaluated where they stand, after the statements above them, also across a top-level await. Function declarations are hoisted. A statement that uses a class, an enum or a top-level const/let declared below it directly (not inside a function it makes) is an error, where Node throws a ReferenceError (TypeScript reports "used before its declaration"). A function made above a declaration may read it once it has run (const get = () => K; const K = 1; get(); gives 1); not yet a destructuring declaration, a let without an initializer or one whose initializer awaits, which must come above the function. A function that reads a module variable before its declaration has run ([1].map((x) => x + K) above const K = [1]) stops the program with Uncaught ReferenceError: Cannot access 'K' before initialization and exit code 1. Unlike Node, where the ReferenceError is an exception a try/catch around the call can catch, it cannot be caught yet: the program ends at the read.

ts
export let calls = 0;
function count(): number {
  calls++;
  return calls;
}
count();
class Counter {
  static readonly first: number = count(); // 2: the statement above ran first
}
console.log(Counter.first);

A file with top-level statements may also declare a function named main, as TypeScript programs often do (or anything else named main: a variable, a class, an import): then, as in Node, the statements are the program, and main is an ordinary function that runs when they call it, with any arguments (main(process.argv.slice(2))), as often as they call it, and its promise handled as they choose (main().catch(…)).

This is where Velt differs from Node: a root file with function main() and no top-level statements runs main by itself, which Node does not. Adding a top-level statement to such a file makes main an ordinary function that runs only when called, so the compiler warns when a file with statements declares a main that nothing calls:

Text
warning: `main` is never called: in a file with top-level statements, the statements are the program and `main` is an ordinary function
  = note: call it from the statements (`main();`) to run it, as in Node
ts
async function main(): Promise<void> {
  await Promise.resolve();
  console.log("hi");
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});

When the top-level statements wait at an await that nothing can settle any more, and nothing else can happen, the program ends as Node's does: Warning: Detected unsettled top-level await at <file>:<line> on stderr and exit code 13 (Async).

An imported module's top-level statements run once, when the module is evaluated, as in Node: the modules are evaluated before the root file's statements run, each after the modules it imports (in the order of its imports, depth first) and before the module that imports it. A circular import finds the module that is still being evaluated and goes on without it. A module imported only for its types (import type { … }, or an import whose every name is type) is not evaluated: neither its statements nor its variables' initializers run. A module's statements run in source order with its module-level variables (module state): each after the initializers above it and before those below it (a variable that statements on both sides of a module-level variable use must be declared with one name and an initializer, for now). An error they throw and don't catch prints Uncaught … and ends the program with exit code 1. In the root file, a promise statement (renderAll();) starts the promise as in Node: it runs up to its first await, the statements after it run, and the program waits for it before exiting (a rejection nobody handles ends it with exit code 1); in a function it is a floating promise, an error. Top-level await is only allowed in the root file, and an imported module's statements can't start asynchronous work yet (a timer, a promise chain, queueMicrotask, spawn): they run before the scheduler starts.

ts
// registry.ts
export const registry: string[] = [];
export function register(name: string): void {
  registry.push(name);
}
register("trim"); // runs when a module imports registry.ts

Re-exports#

Re-exports make one module the public face of others: export { x, y as z } from "…", export type { T } from "…", and export * from "…" (every export of that module; a name the module exports itself or lists by name wins). Re-exports bind nothing in the re-exporting module; importers get the original declaration, and go-to-definition lands there too.

Types and values of one name#

A name may be a type and a value at once, as in TypeScript, which keeps types and values in separate namespaces: a type alias or an interface next to a constant, a let or a function of the same name. A type position uses the type, an expression the value, and typeof in a type position the value.

ts
type guid = string;
const guid = (): guid => "id-1";

interface Point {
  x: number;
  y: number;
}
function Point(x: number, y: number): Point {
  return { x, y };
}

const id: guid = guid();
const p: Point = Point(1, 2);
const make: typeof guid = guid;
console.log(id, p.x + p.y, make()); // id-1 3 id-1
  • export marks each declaration on its own (export type guid = string with a const guid that stays private exports only the type). import { guid }, export { guid }, export { guid } from "…", export * from "…" and import * as m (m.guid as a type and as a value) carry both meanings; import type { guid } and export type { guid } only the type. A name a module exports only as a type (with export type { x }, or export { x } of a name it imports with import type) is a type for its importers too: using it as a value is an error, as in TypeScript (TS1361, TS1362).
  • The same holds in a function body (type Range = [number, number] next to const Range = …), and a value does not hide a type of the same name declared further out (a local const guid in a function with const g: guid still means the module's type), nor a type a value (a module's type Map = … leaves the global Map visible to new Map()).
  • A type used as a value is `T` is a type, not a value, and a value used as a type `f` is a value, not a type (TypeScript's TS2749) with the hint typeof f.
  • Classes, structs and enums are a type and a value already: another declaration of their name is an error (as TypeScript's TS2567 for enums; TypeScript merges an interface into a class of its name, Velt doesn't, Interfaces). Two type declarations (an alias and an interface) or two values of one name are an error too, in a function body as at the top level (two functions of one name in one block included: TypeScript's TS2393, where Node would run the last one).

Namespaces#

A namespace groups types under one name, as in TypeScript: its members are written N.T outside it and T inside it (a type parameter of the same name hides a member). Namespaces nest (A.B.T), namespace A.B { … } declares A holding B, blocks with the same name are one namespace, and a namespace may share its name with an interface or a type (interface Shape and Shape.Circle are different types).

ts
namespace Geo {
  export interface Point {
    x: number;
    y: number;
  }
  type Pair = [Point, Point]; // without `export`: only inside `Geo`
  export type Segment = { ends: Pair; length: number };
}

const a: Geo.Point = { x: 0, y: 0 };
const b: Geo.Point = { x: 3, y: 4 };
const s: Geo.Segment = { ends: [a, b], length: 5 };
console.log(s.ends[1].x, s.length); // 3 5
  • Only members declared with export are visible outside (Geo.Pair is TypeScript's TS2694, namespace `Geo` has no exported member `Pair` ); in a declare namespace every member is. A member declared without export is visible only in its own block, not in another block of the same namespace; two blocks may each declare their own unexported member of the same name (two interfaces declared so do not merge).
  • Inside a namespace, a name used as a type means a type or interface (a nested namespace of the same name does not hide an outer type), and the first part of B.T a namespace.
  • A namespace named like an imported type or value merges with it, as in TypeScript: the bare name is the import, N.T the namespace's member (import { N } from "./lib" with export type N = number there, next to namespace N { export type T = string }). A namespace can't share its name with an imported namespace, import * as N or the import of another module's namespace N (TypeScript's TS2440: import declaration conflicts with local declaration of `N` ).
  • An exported namespace is imported and re-exported by its name, like a type (import { type Geo } from "./geo", export type { Geo } from "./geo"), and through import * as m as m.Geo.Point.
  • declare global { namespace JSX { … } } declares the namespace in its module, as a declare namespace (a global visible in other modules is not supported yet).
  • A namespace holds type aliases, interfaces and namespaces. Values in a namespace (const, function, class, enum: a run-time object in TypeScript) are not supported yet.

Module specifiers#

SpecifierResolves to
"./file", "../dir/file"relative to the importing file, without the extension: file.vlt, file.ts or file.tsx (TypeScript files). A folder is a module through its index file: "./shapes" is shapes.vlt (or .ts, .tsx), else shapes/index.vlt (or .ts, .tsx)
"velt/x"the standard library module x ("velt/fs", "velt/collections", "velt/net/http"): below
an alias from paths in package.vlt"@app/*": "src/*" makes "@app/util/strings" mean src/util/strings.vlt (or .ts, .tsx), resolved like a relative import (package.vlt)
"pkg", "pkg/sub"a dependency from package.vlt: its src/lib.vlt, or src/sub.vlt / src/sub/index.vlt (or .ts, .tsx, as for a relative import) (Packages)
"@scope/pkg", "pkg/sub" of an npm packagewhen no dependency has that name: the npm package in node_modules or the workspace, compiled from the TypeScript source its package.json exports point back to (npm packages)

A specifier must spell file and directory names exactly as they are on disk, case included, on every OS, as TypeScript's forceConsistentCasingInFileNames requires: "./Util" does not load util.vlt on Windows or macOS either, since it would fail on Linux. The error names the file on disk and the specifier to write (import it as "./util"). Files whose names differ only in case (Util.ts next to util.vlt) are different modules everywhere.

Standard library modules#

A "velt/x" specifier names a standard library module: a folder of std/ with an index.vlt, which is the module's public API ("velt/net/http" is std/net/http/index.vlt). The other files of std/ are internal to the module whose folder holds them, and a program or package that imports one gets an error naming that module, with the import to write instead:

Text
error: `velt/encoding/json/scan` is internal to `velt/encoding/json`
  = note: import what you need from "velt/encoding/json", the module's public API

The prelude (std/prelude/) and what implements the language and its globals (std/lang/) are never imported. Standard library code itself imports any std file.

A velt: specifier is an error; std modules are velt/<area>.

velt and std are reserved for the standard library: no package or dependency may have either name (the package name `velt` is reserved for the standard library). The database drivers and the HTTP server are packages, not std modules: "sqlite", "postgres", "redis" and "http-server" are imported by the name of the dependency that provides them (Packages).

The prelude (strings, arrays, Map, Math, JSON, Error, Comparable, Mutex, assert, …) is always in scope without an import (Built-ins).

TypeScript files (.ts and .tsx)#

A module can be a .vlt, .ts or .tsx file, so a folder can be shared with a TypeScript project: Velt reads all three as Velt source, and tsc reads the .ts and .tsx files. A program may mix them freely, and velt run app.ts works like velt run app.vlt.

  • "./util" tries util.vlt, util.ts and util.tsx, then util/index.vlt, util/index.ts and util/index.tsx. When two of the files tried together exist (util.vlt and util.ts), the import is an error that names them: rename or remove one. A file wins over a folder, as in TypeScript; when the folder's index file has another extension (util.ts next to util/index.vlt), the import gets a warning, since the folder module is easy to mistake for the one loaded.
  • A relative import may name the extension, as TypeScript allows with allowImportingTsExtensions: "./util.ts" (or .tsx, .vlt) is exactly that file. As in TypeScript, "./util.js" means util.ts or util.tsx, and "./card.jsx" means card.tsx.
  • JSX is allowed in .tsx and .vlt files. In a .ts file it is an error, as in TypeScript; rename the file to .tsx. The JSX provider is chosen the same way for every file: the /** @jsxImportSource x */ comment before the first token, else the package's jsx.importSource, else velt/jsx. Velt also reads the comment as // @jsxImportSource x; tsc reads only the block comment, so code shared with TypeScript uses that form.
  • Two files in one directory whose names differ only in the extension (dup.vlt and dup.ts) have the same module path: an import without an extension can't tell them apart, and one program can't load both. velt check in a package reports every such pair under src/ and tests/ as an error, whether or not anything imports them.
  • Declaration files (.d.ts) are not modules: Velt never loads them. "./types.d" does not find types.d.ts, and "./types.d.ts" is an error.
  • TypeScript's older type assertion <T>x is an error in a .ts file (in .tsx and .vlt files <T> starts JSX, as in a .tsx file): narrow with typeof or instanceof, or annotate the variable's type.
  • A package can be written in TypeScript: the modules of a dependency ("pkg", "pkg/sub") are found like relative imports' files (src/lib.ts, src/sub.tsx, src/sub/index.ts, …), and without an entry in package.vlt a package's default entry is src/main.vlt, src/main.ts or src/main.tsx (src/lib.* for a library). Standard library modules are .vlt files.

velt check in a package, velt test (*.test.ts, *.test.tsx), velt fmt, velt doc and the language server take .ts and .tsx files along with .vlt ones (tooling).

Runtime declarations#

declare function / declare async function declare runtime (C ABI) functions; the standard library uses them to bind velt_rt_*, and only it may declare those (they take raw runtime handles). Outside the standard library, a declare may only name an export of the native library of its own package, and must match that export exactly (Packages with native code). Anything else is a compile error: a program or a package without native code can't declare C functions such as free or memcpy. Compiler intrinsics (declare intrinsic function, in velt/lang/intrinsics) are reserved for the standard library too. A runtime function std declares writes the string it takes as out or buf (a string builder, or the slot for its result), so passing a place there modifies it, as a modifying method does.

declare system function is the exception every module has: it binds a function of the platform's C library from the compiler's table of the ones it can call safely (pread, fsync, fcntl, ...), checked against the table's C signature for the target (System functions). The standard library uses no other way to reach them, so a package can do everything std does.

Whether a module belongs to the standard library depends on where it was loaded from (the std root), never on its name: a user module whose path would start with std/ (a ./std/ directory) is an error, and no package may be named std.

Planned: extensions scoped to the modules that import them.

Examples#

ts
import { join, basename } from "velt/path";
import { gcd as greatestDivisor } from "velt/math";

export function describe(path: string): string {
  return `${basename(path)} in ${join("/srv", "app")}`;
}

console.log(describe("/tmp/a.txt"), greatestDivisor(12, 18));
ts
import * as path from "velt/path";
import type { Stats } from "velt/fs";

function size(s: Stats): u64 {
  return s.size;
}

export { size as statSize };

console.log(path.join("a", "b"), path.extname("x.vlt"));

A folder module re-exporting its parts (shapes/index.vlt):

ts
export { Circle, area } from "./circle";
export * from "./square";
export type { Shape } from "./shape";

Edit this page on GitHub