Modules and packages
Exports
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 fileEach 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 defaultis an error whose fix is a named export (export function name, orexport { name };), and so is a default import (import x from). extendblocks cannot be exported: they apply wherever their module is loaded.
Imports
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 namesA 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
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: URLimport.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.
const here = import.meta.url;
console.log(here.startsWith("file:///")); // trueconsole.log(import.meta) prints it as Node does, and JSON.stringify leaves resolve out:
[Object: null prototype] { dirname: …, resolve: [Function: resolve], … }import.meta.resolve
resolve resolves as Node does, without checking that the file exists:
| Specifier | Result |
|---|---|
| An absolute URL | as 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 modules | node:<name>: fs is node:fs, _http_agent is node:_http_agent |
| A package name | throws 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===toimport.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, asimport.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.
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 3The 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.
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:
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 Nodeasync 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.
// registry.ts
export const registry: string[] = [];
export function register(name: string): void {
registry.push(name);
}
register("trim"); // runs when a module imports registry.tsRe-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.
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-1exportmarks each declaration on its own (export type guid = stringwith aconst guidthat stays private exports only the type).import { guid },export { guid },export { guid } from "…",export * from "…"andimport * as m(m.guidas a type and as a value) carry both meanings;import type { guid }andexport type { guid }only the type. A name a module exports only as a type (withexport type { x }, orexport { x }of a name it imports withimport 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 toconst Range = …), and a value does not hide a type of the same name declared further out (a localconst guidin a function withconst g: guidstill means the module's type), nor a type a value (a module'stype Map = …leaves the globalMapvisible tonew 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 hinttypeof 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).
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
exportare visible outside (Geo.Pairis TypeScript's TS2694,namespace `Geo` has no exported member `Pair`); in adeclare namespaceevery member is. A member declared withoutexportis 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.Ta namespace. - A namespace named like an imported type or value merges with it, as in TypeScript: the bare
name is the import,
N.Tthe namespace's member (import { N } from "./lib"withexport type N = numberthere, next tonamespace N { export type T = string }). A namespace can't share its name with an imported namespace,import * as Nor the import of another module'snamespace 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 throughimport * as masm.Geo.Point. declare global { namespace JSX { … } }declares the namespace in its module, as adeclare 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
| Specifier | Resolves 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 package | when 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:
error: `velt/encoding/json/scan` is internal to `velt/encoding/json`
= note: import what you need from "velt/encoding/json", the module's public APIThe 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"triesutil.vlt,util.tsandutil.tsx, thenutil/index.vlt,util/index.tsandutil/index.tsx. When two of the files tried together exist (util.vltandutil.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'sindexfile has another extension (util.tsnext toutil/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"meansutil.tsorutil.tsx, and"./card.jsx"meanscard.tsx. - JSX is allowed in
.tsxand.vltfiles. In a.tsfile 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'sjsx.importSource, elsevelt/jsx. Velt also reads the comment as// @jsxImportSource x;tscreads 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.vltanddup.ts) have the same module path: an import without an extension can't tell them apart, and one program can't load both.velt checkin a package reports every such pair undersrc/andtests/as an error, whether or not anything imports them. - Declaration files (
.d.ts) are not modules: Velt never loads them."./types.d"does not findtypes.d.ts, and"./types.d.ts"is an error. - TypeScript's older type assertion
<T>xis an error in a.tsfile (in.tsxand.vltfiles<T>starts JSX, as in a.tsxfile): narrow withtypeoforinstanceof, 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 anentryinpackage.vlta package's default entry issrc/main.vlt,src/main.tsorsrc/main.tsx(src/lib.*for a library). Standard library modules are.vltfiles.
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
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));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):
export { Circle, area } from "./circle";
export * from "./square";
export type { Shape } from "./shape";